mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
ea4032766b
A new .scarftemplate in the public catalog whose only content is
a Hermes skill that teaches an agent how to scaffold a new
Scarf-compatible project — dashboard, optional configuration
schema, optional cron job, AGENTS.md — from a short conversational
interview. Scaffolded projects are usable locally and cleanly
exportable as .scarftemplate bundles later.
The skill itself (~400 lines of structured markdown at
skills/scarf-template-author/SKILL.md) covers:
- When to invoke vs. when to answer inline
- The on-disk project shape Scarf expects
- A 5-question interview flow
- Full widget catalog (all 7 widget types) with JSON shapes
- Config schema design + hard invariants (no defaults on secrets,
`contents.config` must match field count, etc.)
- Cron-job design including the {{PROJECT_DIR}} gotcha
- Step-by-step file writing (dashboard, manifest, AGENTS.md, README)
- Testing + catalog validation instructions
- Common pitfalls + source-of-truth references
Delivered as a .scarftemplate so the install flow's normal
safeguards apply: preview sheet shows one project + one skill
+ zero cron jobs + no config step, uninstall drops both the
project dir and the namespaced skill folder via the existing
lock-file mechanism.
Scope per user sign-off: blank-slate / fully conversational for
v1. Pre-baked archetypes (`monitor`, `dev-dashboard`, etc.) are
deferred to v1.1 pending real usage data on what shapes users
actually ask for.
New Swift test exercises the bundle through the installer's
plan builder — asserts manifest shape, that the skill lands at
~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md,
and that no-config templates correctly skip the manifest cache.
58/58 Swift tests pass; 24/24 Python tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1198 lines
56 KiB
Swift
1198 lines
56 KiB
Swift
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.
|
||
@Suite struct ProjectTemplateServiceTests {
|
||
|
||
@Test func manifestSlugSanitizesPunctuation() {
|
||
let manifest = Self.sampleManifest(id: "alan@w/focus dashboard!")
|
||
#expect(manifest.slug == "alan-w-focus-dashboard")
|
||
}
|
||
|
||
@Test func manifestSlugFallsBackToPlaceholder() {
|
||
let manifest = Self.sampleManifest(id: "////")
|
||
#expect(manifest.slug == "template")
|
||
}
|
||
|
||
@Test func inspectRejectsMissingManifest() throws {
|
||
let dir = try Self.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||
|
||
// A zip with no template.json
|
||
let bundle = try Self.makeBundle(dir: dir, files: [
|
||
"README.md": "hi",
|
||
"AGENTS.md": "hi",
|
||
"dashboard.json": "{}"
|
||
], includeManifest: false)
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
#expect(throws: ProjectTemplateError.self) {
|
||
try service.inspect(zipPath: bundle)
|
||
}
|
||
}
|
||
|
||
@Test func inspectRejectsMissingAgentsMd() throws {
|
||
let dir = try Self.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||
|
||
let bundle = try Self.makeBundle(dir: dir, files: [
|
||
"README.md": "# Readme",
|
||
"dashboard.json": Self.sampleDashboardJSON
|
||
])
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
#expect(throws: ProjectTemplateError.self) {
|
||
try service.inspect(zipPath: bundle)
|
||
}
|
||
}
|
||
|
||
@Test func inspectAcceptsMinimalValidBundle() throws {
|
||
let dir = try Self.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||
|
||
let bundle = try Self.makeBundle(dir: dir, files: [
|
||
"README.md": "# Readme",
|
||
"AGENTS.md": "# Agents",
|
||
"dashboard.json": Self.sampleDashboardJSON
|
||
])
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
|
||
#expect(inspection.manifest.id == "test/example")
|
||
#expect(inspection.manifest.slug == "test-example")
|
||
#expect(inspection.cronJobs.isEmpty)
|
||
#expect(inspection.files.contains("AGENTS.md"))
|
||
}
|
||
|
||
@Test func inspectRejectsContentClaimMismatch() throws {
|
||
let dir = try Self.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||
|
||
// Claim cron: 2 but ship no cron dir → service must reject.
|
||
let manifest = Self.sampleManifest(cron: 2)
|
||
let manifestJSON = try JSONEncoder().encode(manifest)
|
||
let manifestString = String(data: manifestJSON, encoding: .utf8)!
|
||
|
||
let bundle = try Self.makeBundle(dir: dir, files: [
|
||
"README.md": "# Readme",
|
||
"AGENTS.md": "# Agents",
|
||
"dashboard.json": Self.sampleDashboardJSON,
|
||
"template.json": manifestString
|
||
], includeManifest: false)
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
#expect(throws: ProjectTemplateError.self) {
|
||
try service.inspect(zipPath: bundle)
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
static let sampleDashboardJSON = """
|
||
{
|
||
"version": 1,
|
||
"title": "Example",
|
||
"description": "test",
|
||
"sections": []
|
||
}
|
||
"""
|
||
|
||
static func sampleManifest(
|
||
id: String = "test/example",
|
||
cron: Int? = nil,
|
||
skills: [String]? = nil,
|
||
instructions: [String]? = nil,
|
||
configFieldCount: Int? = nil,
|
||
configSchema: TemplateConfigSchema? = nil
|
||
) -> ProjectTemplateManifest {
|
||
// schemaVersion auto-bumps to 2 when a schema is present so tests
|
||
// that exercise the schema path mirror real manifest behaviour.
|
||
let version = (configSchema != nil) ? 2 : 1
|
||
return ProjectTemplateManifest(
|
||
schemaVersion: version,
|
||
id: id,
|
||
name: "Example",
|
||
version: "1.0.0",
|
||
minScarfVersion: nil,
|
||
minHermesVersion: nil,
|
||
author: TemplateAuthor(name: "Tester", url: nil),
|
||
description: "Test template",
|
||
category: nil,
|
||
tags: nil,
|
||
icon: nil,
|
||
screenshots: nil,
|
||
contents: TemplateContents(
|
||
dashboard: true,
|
||
agentsMd: true,
|
||
instructions: instructions,
|
||
skills: skills,
|
||
cron: cron,
|
||
memory: nil,
|
||
config: configFieldCount ?? configSchema?.fields.count
|
||
),
|
||
config: configSchema
|
||
)
|
||
}
|
||
|
||
static func makeTempDir() throws -> String {
|
||
let dir = NSTemporaryDirectory() + "scarf-template-test-" + UUID().uuidString
|
||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||
return dir
|
||
}
|
||
|
||
/// Write files to a staging dir, then zip them into `<dir>/bundle.scarftemplate`
|
||
/// and return its path. When `includeManifest` is true the caller doesn't
|
||
/// need to provide `template.json` — we synthesize a valid one.
|
||
static func makeBundle(
|
||
dir: String,
|
||
files: [String: String],
|
||
includeManifest: Bool = true
|
||
) throws -> String {
|
||
let staging = dir + "/staging"
|
||
try FileManager.default.createDirectory(atPath: staging, withIntermediateDirectories: true)
|
||
|
||
for (relativePath, content) in files {
|
||
let full = staging + "/" + relativePath
|
||
let parent = (full as NSString).deletingLastPathComponent
|
||
if !FileManager.default.fileExists(atPath: parent) {
|
||
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||
}
|
||
try content.data(using: .utf8)!.write(to: URL(fileURLWithPath: full))
|
||
}
|
||
if includeManifest {
|
||
let manifest = sampleManifest()
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = .prettyPrinted
|
||
let data = try encoder.encode(manifest)
|
||
try data.write(to: URL(fileURLWithPath: staging + "/template.json"))
|
||
}
|
||
|
||
let bundlePath = dir + "/bundle.scarftemplate"
|
||
let process = Process()
|
||
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||
process.currentDirectoryURL = URL(fileURLWithPath: staging)
|
||
process.arguments = ["-qq", "-r", bundlePath, "."]
|
||
try process.run()
|
||
process.waitUntilExit()
|
||
#expect(process.terminationStatus == 0)
|
||
return bundlePath
|
||
}
|
||
}
|
||
|
||
/// URL-router has no filesystem side effects — safe to unit-test directly.
|
||
@Suite struct TemplateURLRouterTests {
|
||
|
||
@Test @MainActor func refusesNonScarfScheme() {
|
||
let router = TemplateURLRouter.shared
|
||
router.pendingInstallURL = nil
|
||
let ok = router.handle(URL(string: "https://example.com/foo")!)
|
||
#expect(ok == false)
|
||
#expect(router.pendingInstallURL == nil)
|
||
}
|
||
|
||
@Test @MainActor func refusesUnknownHost() {
|
||
let router = TemplateURLRouter.shared
|
||
router.pendingInstallURL = nil
|
||
let ok = router.handle(URL(string: "scarf://bogus?url=https://example.com/x.scarftemplate")!)
|
||
#expect(ok == false)
|
||
#expect(router.pendingInstallURL == nil)
|
||
}
|
||
|
||
@Test @MainActor func refusesNonHttpsPayload() {
|
||
let router = TemplateURLRouter.shared
|
||
router.pendingInstallURL = nil
|
||
let ok = router.handle(URL(string: "scarf://install?url=file:///etc/passwd")!)
|
||
#expect(ok == false)
|
||
#expect(router.pendingInstallURL == nil)
|
||
}
|
||
|
||
@Test @MainActor func acceptsFileURLWithScarftemplateExtension() {
|
||
let router = TemplateURLRouter.shared
|
||
router.pendingInstallURL = nil
|
||
let path = "/tmp/example.scarftemplate"
|
||
let ok = router.handle(URL(fileURLWithPath: path))
|
||
#expect(ok)
|
||
#expect(router.pendingInstallURL?.isFileURL == true)
|
||
#expect(router.pendingInstallURL?.path == path)
|
||
router.consume()
|
||
}
|
||
|
||
@Test @MainActor func refusesFileURLWithOtherExtension() {
|
||
let router = TemplateURLRouter.shared
|
||
router.pendingInstallURL = nil
|
||
let ok = router.handle(URL(fileURLWithPath: "/tmp/somefile.zip"))
|
||
#expect(ok == false)
|
||
#expect(router.pendingInstallURL == nil)
|
||
}
|
||
|
||
@Test @MainActor func acceptsHttpsInstallUrl() {
|
||
let router = TemplateURLRouter.shared
|
||
router.pendingInstallURL = nil
|
||
let target = "https://example.com/foo.scarftemplate"
|
||
let ok = router.handle(URL(string: "scarf://install?url=\(target)")!)
|
||
#expect(ok)
|
||
#expect(router.pendingInstallURL?.absoluteString == target)
|
||
router.consume()
|
||
}
|
||
}
|
||
|
||
/// End-to-end install test against a minimal bundle (dashboard + README +
|
||
/// AGENTS.md, no skills/cron/memory). Exercises the full install path
|
||
/// through `preflight → createProjectFiles → registerProject →
|
||
/// writeLockFile`. We avoid touching user state by:
|
||
/// 1. Picking a temp `projectDir` under `NSTemporaryDirectory()`.
|
||
/// 2. Snapshotting and restoring `~/.hermes/scarf/projects.json` around
|
||
/// each test so the registry write is reversible.
|
||
/// Skills/cron/memory paths aren't touched because the test bundles claim
|
||
/// none. That's the intentional v1 coverage: the project-dir side effects
|
||
/// are exhaustively tested; global-state side effects (skills namespace,
|
||
/// cron CLI, memory append) are covered by manual verification per the
|
||
/// plan's step 7.
|
||
@Suite(.serialized) struct ProjectTemplateInstallerTests {
|
||
|
||
@Test func installsMinimalBundleAndWritesLockFile() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
let parentDir = scratch + "/parent"
|
||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"README.md": "# Minimal",
|
||
"AGENTS.md": "# Agent notes",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
])
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||
|
||
let registryBefore = Self.snapshotRegistry()
|
||
defer { Self.restoreRegistry(registryBefore) }
|
||
|
||
let installer = ProjectTemplateInstaller(context: .local)
|
||
let entry = try installer.install(plan: plan)
|
||
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/AGENTS.md"))
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/README.md"))
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/dashboard.json"))
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/template.lock.json"))
|
||
#expect(entry.path == plan.projectDir)
|
||
|
||
let lockData = try Data(contentsOf: URL(fileURLWithPath: plan.projectDir + "/.scarf/template.lock.json"))
|
||
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||
#expect(lock.templateId == inspection.manifest.id)
|
||
#expect(lock.templateVersion == inspection.manifest.version)
|
||
#expect(lock.projectFiles.contains(plan.projectDir + "/AGENTS.md"))
|
||
#expect(lock.cronJobNames.isEmpty)
|
||
#expect(lock.memoryBlockId == nil)
|
||
}
|
||
|
||
@Test func preflightRejectsExistingProjectDir() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
let parentDir = scratch + "/parent"
|
||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"README.md": "# Minimal",
|
||
"AGENTS.md": "# Agent notes",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
])
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||
|
||
// Simulate a concurrent creation between buildPlan and install.
|
||
try FileManager.default.createDirectory(atPath: plan.projectDir, withIntermediateDirectories: true)
|
||
|
||
let installer = ProjectTemplateInstaller(context: .local)
|
||
#expect(throws: ProjectTemplateError.self) {
|
||
try installer.install(plan: plan)
|
||
}
|
||
}
|
||
|
||
@Test func buildPlanRefusesDuplicateProjectDir() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
let parentDir = scratch + "/parent"
|
||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"README.md": "# Minimal",
|
||
"AGENTS.md": "# Agent notes",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
])
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
|
||
// Pre-create the slugged project dir so buildPlan's collision check
|
||
// fires before we get to install.
|
||
let slugDir = parentDir + "/" + inspection.manifest.slug
|
||
try FileManager.default.createDirectory(atPath: slugDir, withIntermediateDirectories: true)
|
||
|
||
#expect(throws: ProjectTemplateError.self) {
|
||
try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||
}
|
||
}
|
||
|
||
// MARK: - Cron prompt token substitution
|
||
|
||
@Test func substituteCronTokensResolvesProjectDir() throws {
|
||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||
let raw = "Read {{PROJECT_DIR}}/.scarf/config.json"
|
||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||
#expect(resolved == "Read \(plan.projectDir)/.scarf/config.json")
|
||
// Original placeholder must be fully replaced — a lingering
|
||
// {{PROJECT_DIR}} would leave the cron job trying to read a
|
||
// literal file named `{{PROJECT_DIR}}` which doesn't exist.
|
||
#expect(resolved.contains("{{PROJECT_DIR}}") == false)
|
||
}
|
||
|
||
@Test func substituteCronTokensResolvesIdAndSlug() throws {
|
||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||
let raw = "Log as {{TEMPLATE_ID}} (slug {{TEMPLATE_SLUG}})"
|
||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||
#expect(resolved.contains(plan.manifest.id))
|
||
#expect(resolved.contains(plan.manifest.slug))
|
||
#expect(resolved.contains("{{TEMPLATE_ID}}") == false)
|
||
#expect(resolved.contains("{{TEMPLATE_SLUG}}") == false)
|
||
}
|
||
|
||
@Test func substituteCronTokensLeavesUnknownTokensUntouched() throws {
|
||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||
let raw = "{{PROJECT_DIR}} but keep {{UNSUPPORTED}} literal"
|
||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||
#expect(resolved.contains(plan.projectDir))
|
||
// Unsupported placeholders pass through verbatim — template
|
||
// authors will notice in testing that their token didn't get
|
||
// replaced and either use a supported one or request a new one.
|
||
#expect(resolved.contains("{{UNSUPPORTED}}"))
|
||
}
|
||
|
||
@Test func substituteCronTokensRepeatsWithinString() throws {
|
||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||
let raw = "Read {{PROJECT_DIR}}/a and write {{PROJECT_DIR}}/b"
|
||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||
// Both occurrences should be replaced — not just the first.
|
||
// A single-replace bug here would leave the second relative,
|
||
// causing the same CWD issue this whole feature was meant to
|
||
// fix.
|
||
let count = resolved.components(separatedBy: plan.projectDir).count - 1
|
||
#expect(count == 2)
|
||
}
|
||
|
||
// MARK: - Registry snapshot helpers
|
||
|
||
/// 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? {
|
||
TestRegistryLock.acquireAndSnapshot()
|
||
}
|
||
|
||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||
TestRegistryLock.restore(snapshot)
|
||
}
|
||
}
|
||
|
||
/// End-to-end install + uninstall test: install a minimal bundle, uninstall
|
||
/// it, verify every tracked file is gone, the registry is restored to its
|
||
/// pre-install state, and user-added files (if any) are preserved. Scoped
|
||
/// to bundles with no skills/cron/memory so no global state is touched.
|
||
@Suite(.serialized) struct ProjectTemplateUninstallerTests {
|
||
|
||
@Test func roundTripsInstallThenUninstall() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
let parentDir = scratch + "/parent"
|
||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"README.md": "# Minimal",
|
||
"AGENTS.md": "# Agent notes",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
])
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||
|
||
let registryBefore = Self.snapshotRegistry()
|
||
defer { Self.restoreRegistry(registryBefore) }
|
||
|
||
let installer = ProjectTemplateInstaller(context: .local)
|
||
let entry = try installer.install(plan: plan)
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
|
||
|
||
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||
#expect(uninstaller.isTemplateInstalled(project: entry))
|
||
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||
#expect(uninstallPlan.projectFilesToRemove.count == 4) // README, AGENTS, dashboard.json, lock
|
||
#expect(uninstallPlan.extraProjectEntries.isEmpty)
|
||
#expect(uninstallPlan.projectDirBecomesEmpty)
|
||
#expect(uninstallPlan.skillsNamespaceDir == nil)
|
||
#expect(uninstallPlan.cronJobsToRemove.isEmpty)
|
||
#expect(uninstallPlan.memoryBlockPresent == false)
|
||
|
||
try uninstaller.uninstall(plan: uninstallPlan)
|
||
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir) == false)
|
||
// Registry entry gone — length matches pre-install snapshot.
|
||
let service2 = ProjectDashboardService(context: .local)
|
||
let registryAfter = service2.loadRegistry()
|
||
#expect(registryAfter.projects.contains(where: { $0.path == entry.path }) == false)
|
||
}
|
||
|
||
@Test func preservesUserAddedFiles() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
let parentDir = scratch + "/parent"
|
||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"README.md": "# Minimal",
|
||
"AGENTS.md": "# Agent notes",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
])
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||
|
||
let registryBefore = Self.snapshotRegistry()
|
||
defer { Self.restoreRegistry(registryBefore) }
|
||
|
||
let installer = ProjectTemplateInstaller(context: .local)
|
||
let entry = try installer.install(plan: plan)
|
||
|
||
// Simulate the user / agent creating files post-install.
|
||
let userFile = plan.projectDir + "/sites.txt"
|
||
try "https://example.com\n".data(using: .utf8)!
|
||
.write(to: URL(fileURLWithPath: userFile))
|
||
|
||
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||
#expect(uninstallPlan.extraProjectEntries.contains(userFile))
|
||
#expect(uninstallPlan.projectDirBecomesEmpty == false)
|
||
|
||
try uninstaller.uninstall(plan: uninstallPlan)
|
||
|
||
// Project dir should still exist because sites.txt is there.
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
|
||
#expect(FileManager.default.fileExists(atPath: userFile))
|
||
// Lock-tracked files are gone.
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/AGENTS.md") == false)
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/README.md") == false)
|
||
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/template.lock.json") == false)
|
||
}
|
||
|
||
@Test func loadUninstallPlanRejectsProjectWithoutLock() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
try FileManager.default.createDirectory(atPath: scratch + "/bare", withIntermediateDirectories: true)
|
||
let entry = ProjectEntry(name: "Bare", path: scratch + "/bare")
|
||
|
||
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||
#expect(uninstaller.isTemplateInstalled(project: entry) == false)
|
||
#expect(throws: ProjectTemplateError.self) {
|
||
try uninstaller.loadUninstallPlan(for: entry)
|
||
}
|
||
}
|
||
|
||
// MARK: - Registry snapshot helpers (dup'd intentionally from
|
||
// 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? {
|
||
TestRegistryLock.acquireAndSnapshot()
|
||
}
|
||
|
||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||
TestRegistryLock.restore(snapshot)
|
||
}
|
||
}
|
||
|
||
/// End-to-end tests for manifest schemaVersion 2 (template configuration).
|
||
/// Exercises the full cycle: inspect → buildPlan → install → uninstall
|
||
/// against a synthesized schemaful bundle. Uses an isolated Keychain
|
||
/// service suffix so no leftover login-Keychain items remain after the
|
||
/// test — every secret we write is deleted on teardown.
|
||
@Suite(.serialized) struct ProjectTemplateConfigInstallTests {
|
||
|
||
/// Minimal schemaful manifest with one non-secret field + one
|
||
/// secret field. Written into the synthesized `.scarftemplate`
|
||
/// bundle for the round-trip tests.
|
||
static func makeSchemafulManifest() -> ProjectTemplateManifest {
|
||
ProjectTemplateServiceTests.sampleManifest(
|
||
id: "tester/configured",
|
||
configSchema: TemplateConfigSchema(
|
||
fields: [
|
||
.init(key: "site_url", type: .string, label: "Site URL",
|
||
description: "where to ping", required: true, placeholder: nil,
|
||
defaultValue: nil, options: nil, minLength: nil,
|
||
maxLength: nil, pattern: nil, minNumber: nil,
|
||
maxNumber: nil, step: nil, itemType: nil,
|
||
minItems: nil, maxItems: nil),
|
||
.init(key: "api_token", type: .secret, label: "API Token",
|
||
description: nil, required: true, placeholder: nil,
|
||
defaultValue: nil, options: nil, minLength: nil,
|
||
maxLength: nil, pattern: nil, minNumber: nil,
|
||
maxNumber: nil, step: nil, itemType: nil,
|
||
minItems: nil, maxItems: nil),
|
||
],
|
||
modelRecommendation: nil
|
||
)
|
||
)
|
||
}
|
||
|
||
@Test func inspectAcceptsSchemaV2Bundle() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
|
||
let manifest = Self.makeSchemafulManifest()
|
||
let manifestData = try JSONEncoder().encode(manifest)
|
||
let manifestString = String(data: manifestData, encoding: .utf8)!
|
||
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"template.json": manifestString,
|
||
"README.md": "# r",
|
||
"AGENTS.md": "# a",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
], includeManifest: false)
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
|
||
#expect(inspection.manifest.schemaVersion == 2)
|
||
#expect(inspection.manifest.config?.fields.count == 2)
|
||
}
|
||
|
||
@Test func buildPlanSurfacesSchemaAndQueuesConfigFiles() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
|
||
let manifest = Self.makeSchemafulManifest()
|
||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"template.json": manifestJSON,
|
||
"README.md": "# r", "AGENTS.md": "# a",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
], includeManifest: false)
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||
|
||
// Schema carried through the plan.
|
||
#expect(plan.configSchema?.fields.count == 2)
|
||
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||
// config.json + manifest.json entries in projectFiles.
|
||
let destinations = plan.projectFiles.map(\.destinationPath)
|
||
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||
}
|
||
|
||
@Test func verifyClaimsRejectsConfigCountMismatch() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
|
||
// Hand-build a manifest whose contents.config claim (2) doesn't
|
||
// match its schema.fields.count (1) — validator should reject.
|
||
let schema = TemplateConfigSchema(
|
||
fields: [
|
||
.init(key: "only", type: .string, label: "Only",
|
||
description: nil, required: false, placeholder: nil,
|
||
defaultValue: nil, options: nil, minLength: nil,
|
||
maxLength: nil, pattern: nil, minNumber: nil,
|
||
maxNumber: nil, step: nil, itemType: nil,
|
||
minItems: nil, maxItems: nil)
|
||
],
|
||
modelRecommendation: nil
|
||
)
|
||
let bogus = ProjectTemplateServiceTests.sampleManifest(
|
||
id: "tester/mismatch",
|
||
configFieldCount: 2, // claim lies
|
||
configSchema: schema // reality is 1
|
||
)
|
||
let manifestJSON = String(data: try JSONEncoder().encode(bogus), encoding: .utf8)!
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"template.json": manifestJSON,
|
||
"README.md": "# r", "AGENTS.md": "# a",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
], includeManifest: false)
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
#expect(throws: ProjectTemplateError.self) {
|
||
try service.inspect(zipPath: bundle)
|
||
}
|
||
}
|
||
|
||
@Test func installWritesConfigJsonAndManifestCache() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
let parentDir = scratch + "/parent"
|
||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||
|
||
let manifest = Self.makeSchemafulManifest()
|
||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"template.json": manifestJSON,
|
||
"README.md": "# r", "AGENTS.md": "# a",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
], includeManifest: false)
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||
|
||
// Isolated Keychain service suffix so the test doesn't touch
|
||
// the real login Keychain.
|
||
let suffix = "tests-" + UUID().uuidString
|
||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||
let configService = ProjectConfigService(keychain: keychain)
|
||
|
||
// Store secret via the service (VM would do this before install).
|
||
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||
let secretRef = try configService.storeSecret(
|
||
templateSlug: manifest.slug,
|
||
fieldKey: "api_token",
|
||
project: project,
|
||
secret: Data("sk-top-secret".utf8)
|
||
)
|
||
plan.configValues = [
|
||
"site_url": .string("https://example.com"),
|
||
"api_token": secretRef
|
||
]
|
||
|
||
let registryBefore = Self.snapshotRegistry()
|
||
defer { Self.restoreRegistry(registryBefore) }
|
||
|
||
let installer = ProjectTemplateInstaller(context: .local)
|
||
_ = try installer.install(plan: plan)
|
||
|
||
// config.json landed with non-secret values + keychain ref.
|
||
let configPath = plan.projectDir + "/.scarf/config.json"
|
||
#expect(FileManager.default.fileExists(atPath: configPath))
|
||
let configData = try Data(contentsOf: URL(fileURLWithPath: configPath))
|
||
let configFile = try JSONDecoder().decode(ProjectConfigFile.self, from: configData)
|
||
#expect(configFile.values["site_url"] == .string("https://example.com"))
|
||
if case .keychainRef(let uri) = configFile.values["api_token"] {
|
||
#expect(uri.hasPrefix("keychain://"))
|
||
} else {
|
||
Issue.record("api_token should have been stored as keychainRef")
|
||
}
|
||
|
||
// manifest.json cache landed for the post-install editor.
|
||
let cachePath = plan.projectDir + "/.scarf/manifest.json"
|
||
#expect(FileManager.default.fileExists(atPath: cachePath))
|
||
let cachedManifest = try JSONDecoder().decode(
|
||
ProjectTemplateManifest.self,
|
||
from: Data(contentsOf: URL(fileURLWithPath: cachePath))
|
||
)
|
||
#expect(cachedManifest.config?.fields.count == 2)
|
||
|
||
// Lock file records the keychain item so uninstall can clean up.
|
||
let lockPath = plan.projectDir + "/.scarf/template.lock.json"
|
||
let lockData = try Data(contentsOf: URL(fileURLWithPath: lockPath))
|
||
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||
#expect(lock.configKeychainItems?.count == 1)
|
||
#expect(lock.configFields == ["site_url", "api_token"])
|
||
|
||
// Clean up the real Keychain entry we created outside the
|
||
// test-suffixed namespace (storeSecret uses real service name
|
||
// because the test's config-service wasn't isolated for this
|
||
// call's secret; we manually delete via our test keychain).
|
||
if let ref = TemplateKeychainRef.parse(
|
||
(configFile.values["api_token"].flatMap { v -> String? in
|
||
if case .keychainRef(let u) = v { return u } else { return nil }
|
||
}) ?? ""
|
||
) {
|
||
try? ProjectConfigKeychain().delete(ref: ref)
|
||
}
|
||
}
|
||
|
||
@Test func uninstallDeletesKeychainItemsViaLock() throws {
|
||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||
let parentDir = scratch + "/parent"
|
||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||
|
||
let manifest = Self.makeSchemafulManifest()
|
||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||
"template.json": manifestJSON,
|
||
"README.md": "# r", "AGENTS.md": "# a",
|
||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||
], includeManifest: false)
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||
|
||
// Real Keychain — we store, install, then uninstall and verify
|
||
// the item is gone. Uses the real service name (no test suffix)
|
||
// because the installer + uninstaller go through their own
|
||
// ProjectConfigKeychain instances without a suffix.
|
||
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||
let configService = ProjectConfigService()
|
||
let secretRef = try configService.storeSecret(
|
||
templateSlug: manifest.slug,
|
||
fieldKey: "api_token",
|
||
project: project,
|
||
secret: Data("delete-me".utf8)
|
||
)
|
||
plan.configValues = [
|
||
"site_url": .string("https://example.com"),
|
||
"api_token": secretRef
|
||
]
|
||
|
||
let registryBefore = Self.snapshotRegistry()
|
||
defer { Self.restoreRegistry(registryBefore) }
|
||
|
||
let installer = ProjectTemplateInstaller(context: .local)
|
||
let entry = try installer.install(plan: plan)
|
||
|
||
// Verify the secret is there before uninstall.
|
||
guard case .keychainRef(let uri) = secretRef,
|
||
let ref = TemplateKeychainRef.parse(uri) else {
|
||
Issue.record("expected secret to be a keychainRef")
|
||
return
|
||
}
|
||
#expect((try ProjectConfigKeychain().get(ref: ref)) == Data("delete-me".utf8))
|
||
|
||
// Uninstall → secret should be gone.
|
||
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||
try uninstaller.uninstall(plan: uninstallPlan)
|
||
|
||
#expect((try ProjectConfigKeychain().get(ref: ref)) == nil)
|
||
}
|
||
|
||
// 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? {
|
||
TestRegistryLock.acquireAndSnapshot()
|
||
}
|
||
|
||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||
TestRegistryLock.restore(snapshot)
|
||
}
|
||
}
|
||
|
||
/// State-machine tests for `TemplateInstallerViewModel`. The install
|
||
/// flow's configure step is driven entirely through the VM — the view
|
||
/// transitions `.awaitingParentDirectory → .awaitingConfig → .planned`
|
||
/// based on `submitConfig(values:)` / `cancelConfig()` calls. If those
|
||
/// transitions break, the user lands on the wrong sheet stage (or no
|
||
/// sheet at all, as in the v1.1.0 regression where the config sheet's
|
||
/// internal `dismiss()` tore down the outer install sheet before
|
||
/// submitConfig had a chance to fire).
|
||
@Suite(.serialized) @MainActor struct TemplateInstallerViewModelTests {
|
||
|
||
@Test func submitConfigStashesValuesAndTransitionsToPlanned() throws {
|
||
let vm = TemplateInstallerViewModel(context: .local)
|
||
// Seed the VM with an awaiting-config plan (schema-ful).
|
||
let plan = try Self.makePlanWithConfigSchema()
|
||
vm.plan = plan
|
||
vm.stage = .awaitingConfig
|
||
|
||
let values: [String: TemplateConfigValue] = [
|
||
"site_url": .string("https://example.com")
|
||
]
|
||
vm.submitConfig(values: values)
|
||
|
||
// Stage must advance past the configure step, values must land
|
||
// on the plan where install() will pick them up.
|
||
if case .planned = vm.stage {
|
||
// ok
|
||
} else {
|
||
Issue.record("expected .planned, got \(vm.stage)")
|
||
}
|
||
#expect(vm.plan?.configValues["site_url"] == .string("https://example.com"))
|
||
}
|
||
|
||
@Test func cancelConfigReturnsToAwaitingParentDirectory() throws {
|
||
let vm = TemplateInstallerViewModel(context: .local)
|
||
vm.plan = try Self.makePlanWithConfigSchema()
|
||
vm.stage = .awaitingConfig
|
||
|
||
vm.cancelConfig()
|
||
|
||
if case .awaitingParentDirectory = vm.stage {
|
||
// ok — user can re-pick the parent dir or fully cancel
|
||
} else {
|
||
Issue.record("expected .awaitingParentDirectory, got \(vm.stage)")
|
||
}
|
||
// Plan is preserved so re-entering the configure step doesn't
|
||
// re-run buildPlan.
|
||
#expect(vm.plan != nil)
|
||
}
|
||
|
||
@Test func submitConfigNoOpWhenPlanIsNil() {
|
||
let vm = TemplateInstallerViewModel(context: .local)
|
||
vm.plan = nil
|
||
vm.stage = .awaitingConfig
|
||
vm.submitConfig(values: ["k": .string("v")])
|
||
// With no plan, the call should be silent — no crash, stage
|
||
// stays where it was. (Defensive guard in submitConfig.)
|
||
if case .awaitingConfig = vm.stage {
|
||
// ok
|
||
} else {
|
||
Issue.record("expected stage to remain .awaitingConfig when plan is nil; got \(vm.stage)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Fixture
|
||
|
||
/// Build a `TemplateInstallPlan` carrying a single-field config
|
||
/// schema. Exists as a local helper rather than a shared one
|
||
/// because no other suite needs it.
|
||
nonisolated static func makePlanWithConfigSchema() throws -> TemplateInstallPlan {
|
||
let schema = TemplateConfigSchema(
|
||
fields: [
|
||
.init(key: "site_url", type: .string, label: "Site URL",
|
||
description: nil, required: true, placeholder: nil,
|
||
defaultValue: nil, options: nil, minLength: nil,
|
||
maxLength: nil, pattern: nil, minNumber: nil,
|
||
maxNumber: nil, step: nil, itemType: nil,
|
||
minItems: nil, maxItems: nil)
|
||
],
|
||
modelRecommendation: nil
|
||
)
|
||
let manifest = ProjectTemplateServiceTests.sampleManifest(
|
||
id: "tester/vm-transitions",
|
||
configSchema: schema
|
||
)
|
||
let tmp = try ProjectTemplateServiceTests.makeTempDir()
|
||
// Not a real bundle dir — we never unzip or install from this
|
||
// plan, we only test state transitions that don't touch disk.
|
||
return TemplateInstallPlan(
|
||
manifest: manifest,
|
||
unpackedDir: tmp,
|
||
projectDir: tmp + "/project",
|
||
projectFiles: [],
|
||
skillsNamespaceDir: nil,
|
||
skillsFiles: [],
|
||
cronJobs: [],
|
||
memoryAppendix: nil,
|
||
memoryPath: ServerContext.local.paths.memoryMD,
|
||
projectRegistryName: "VM Transitions",
|
||
configSchema: schema,
|
||
configValues: [:],
|
||
manifestCachePath: tmp + "/project/.scarf/manifest.json"
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Validates every `.scarftemplate` shipped under `templates/<author>/<name>/`
|
||
/// in the repo. A template whose manifest, `contents` claim, or file set is
|
||
/// out of sync will fail here — so shipped templates can't silently rot.
|
||
@Suite struct ProjectTemplateExampleTemplateTests {
|
||
|
||
@Test func siteStatusCheckerParsesAndPlans() throws {
|
||
let bundle = try Self.locateExample(author: "awizemann", name: "site-status-checker")
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
|
||
#expect(inspection.manifest.id == "awizemann/site-status-checker")
|
||
#expect(inspection.manifest.schemaVersion == 2) // config-enabled
|
||
#expect(inspection.manifest.contents.dashboard)
|
||
#expect(inspection.manifest.contents.agentsMd)
|
||
#expect(inspection.manifest.contents.cron == 1)
|
||
#expect(inspection.manifest.contents.config == 2)
|
||
#expect(inspection.cronJobs.count == 1)
|
||
#expect(inspection.cronJobs.first?.name == "Check site status")
|
||
#expect(inspection.cronJobs.first?.schedule == "0 9 * * *")
|
||
|
||
// Schema assertions — the two fields we declared should survive
|
||
// unzip + parse + validate with their constraints intact.
|
||
let schema = try #require(inspection.manifest.config)
|
||
#expect(schema.fields.count == 2)
|
||
let sitesField = try #require(schema.field(for: "sites"))
|
||
#expect(sitesField.type == .list)
|
||
#expect(sitesField.itemType == "string")
|
||
#expect(sitesField.required == true)
|
||
#expect(sitesField.minItems == 1)
|
||
#expect(sitesField.maxItems == 25)
|
||
let timeoutField = try #require(schema.field(for: "timeout_seconds"))
|
||
#expect(timeoutField.type == .number)
|
||
#expect(timeoutField.minNumber == 1)
|
||
#expect(timeoutField.maxNumber == 60)
|
||
#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-site-status-checker"))
|
||
#expect(plan.skillsFiles.isEmpty)
|
||
#expect(plan.memoryAppendix == nil)
|
||
#expect(plan.cronJobs.count == 1)
|
||
#expect(plan.configSchema?.fields.count == 2)
|
||
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||
// Plan queues both config.json + manifest.json in projectFiles.
|
||
let destinations = plan.projectFiles.map(\.destinationPath)
|
||
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||
// Cron job name gets prefixed with the template tag so users can
|
||
// find + remove it later.
|
||
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/site-status-checker] Check site status")
|
||
|
||
// Verify the bundled dashboard.json decodes against the same
|
||
// `ProjectDashboard` struct the app uses at runtime — catches drift
|
||
// between template-author conventions and the actual renderer
|
||
// (e.g. a widget type that ProjectsView doesn't know, a
|
||
// non-number value for a stat, etc.).
|
||
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 == "Site Status")
|
||
// Four sections: Current Status (stats), Watched Sites (list),
|
||
// Live Site Preview (webview — drives the Site tab), How to Use (text).
|
||
#expect(dashboard.sections.count == 4)
|
||
|
||
// First section should have three stat widgets that the cron job
|
||
// updates by value. Assert titles + types so the AGENTS.md contract
|
||
// can't drift from the actual dashboard.
|
||
let statsSection = dashboard.sections[0]
|
||
#expect(statsSection.title == "Current Status")
|
||
let statTitles = statsSection.widgets.filter { $0.type == "stat" }.map(\.title)
|
||
#expect(statTitles.contains("Sites Up"))
|
||
#expect(statTitles.contains("Sites Down"))
|
||
#expect(statTitles.contains("Last Checked"))
|
||
|
||
// Live Site Preview section must contain exactly one webview
|
||
// widget. The presence of any webview widget is what makes Scarf
|
||
// expose the Site tab next to Dashboard, so losing this section
|
||
// would silently drop a user-visible feature. The cron job
|
||
// rewrites this widget's `url` to the first configured site on
|
||
// every run — AGENTS.md documents the contract.
|
||
let previewSection = dashboard.sections[2]
|
||
#expect(previewSection.title == "Live Site Preview")
|
||
let webviews = previewSection.widgets.filter { $0.type == "webview" }
|
||
#expect(webviews.count == 1)
|
||
#expect(webviews.first?.title == "First Watched Site")
|
||
#expect((webviews.first?.url ?? "").isEmpty == false)
|
||
|
||
// Cron prompt references .scarf/config.json (where values.sites
|
||
// + values.timeout_seconds live), the dashboard/log it writes,
|
||
// and the {{PROJECT_DIR}} placeholder the installer resolves
|
||
// at install time. If either stops being referenced, the cron
|
||
// wouldn't know which data to read or where to write results.
|
||
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
||
#expect(cronPrompt.contains("config.json"))
|
||
#expect(cronPrompt.contains("values.sites"))
|
||
#expect(cronPrompt.contains("dashboard.json"))
|
||
#expect(cronPrompt.contains("status-log.md"))
|
||
// {{PROJECT_DIR}} must remain UNRESOLVED in the bundle — the
|
||
// installer substitutes it at install time. If someone
|
||
// accidentally baked an absolute path into the template, that
|
||
// path would follow every install to every user's machine.
|
||
#expect(cronPrompt.contains("{{PROJECT_DIR}}"))
|
||
}
|
||
|
||
/// Exercises the second shipped template — `awizemann/template-author` —
|
||
/// which is a skill-only bundle (no config, no cron, no memory). The
|
||
/// shape is deliberately different from site-status-checker so a
|
||
/// regression in the installer's "no config, no cron" path can't hide
|
||
/// behind the richer example template. Also asserts the skill lands
|
||
/// under the expected namespaced path so Hermes's recursive skill
|
||
/// discovery finds it.
|
||
@Test func templateAuthorParsesAndPlans() throws {
|
||
let bundle = try Self.locateExample(author: "awizemann", name: "template-author")
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: bundle)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
|
||
// Manifest shape: schemaVersion 2 (contains `skills` claim, which
|
||
// wasn't part of v1), no config, no cron, one skill.
|
||
#expect(inspection.manifest.id == "awizemann/template-author")
|
||
#expect(inspection.manifest.name == "Scarf Template Author")
|
||
#expect(inspection.manifest.version == "1.0.0")
|
||
#expect(inspection.manifest.schemaVersion == 2)
|
||
#expect(inspection.manifest.contents.dashboard)
|
||
#expect(inspection.manifest.contents.agentsMd)
|
||
#expect(inspection.manifest.contents.cron == nil)
|
||
#expect(inspection.manifest.contents.config == nil)
|
||
#expect(inspection.manifest.contents.memory == nil)
|
||
#expect(inspection.manifest.contents.skills == ["scarf-template-author"])
|
||
#expect(inspection.manifest.config == nil)
|
||
#expect(inspection.cronJobs.isEmpty)
|
||
|
||
// Plan: empty config, empty cron, but one skill queued for install
|
||
// under the template's namespaced dir. The namespace path has to
|
||
// match what the uninstaller wipes — `skills/templates/<slug>` —
|
||
// or uninstall leaves orphan skill files.
|
||
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-template-author"))
|
||
#expect(plan.cronJobs.isEmpty)
|
||
#expect(plan.configSchema == nil)
|
||
#expect(plan.configValues.isEmpty)
|
||
#expect(plan.memoryAppendix == nil)
|
||
|
||
// The skill should land at
|
||
// `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`
|
||
// — namespace dir + skill folder + SKILL.md. Anything else
|
||
// breaks Hermes's recursive discovery or the uninstaller's
|
||
// `rm -rf` on the namespace dir.
|
||
let namespaceDir = try #require(plan.skillsNamespaceDir)
|
||
#expect(namespaceDir.hasSuffix("/skills/templates/awizemann-template-author"))
|
||
#expect(plan.skillsFiles.count == 1)
|
||
let skillDest = try #require(plan.skillsFiles.first?.destinationPath)
|
||
#expect(skillDest.hasSuffix("/scarf-template-author/SKILL.md"))
|
||
#expect(skillDest.hasPrefix(namespaceDir))
|
||
|
||
// No-config templates deliberately skip the manifest cache —
|
||
// the dashboard's Configuration button only shows up when
|
||
// `.scarf/manifest.json` exists, so a skill-only template
|
||
// like this one correctly doesn't surface that button.
|
||
// (See ProjectTemplateService.buildPlan lines 198–227.)
|
||
#expect(plan.manifestCachePath == nil)
|
||
}
|
||
|
||
/// Resolve the example bundle path robustly. Unit-test working dirs
|
||
/// differ between `xcodebuild test` (project root) and an Xcode IDE
|
||
/// run (build-output dir), so we walk up from this source file until
|
||
/// we find the repo root. Templates live at
|
||
/// `templates/<author>/<name>/<name>.scarftemplate` per the catalog
|
||
/// layout (see `templates/README.md`).
|
||
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")
|
||
}
|
||
}
|
||
|
||
/// Round-trips a real project structure through the exporter and back into
|
||
/// the service. Does NOT run the installer (which would write to
|
||
/// ~/.hermes) — it verifies the produced bundle is valid, and stops there.
|
||
@Suite struct ProjectTemplateExportTests {
|
||
|
||
@Test func roundTripsMinimalProject() throws {
|
||
let fakeProject = NSTemporaryDirectory() + "scarf-project-" + UUID().uuidString
|
||
try FileManager.default.createDirectory(atPath: fakeProject + "/.scarf", withIntermediateDirectories: true)
|
||
defer { try? FileManager.default.removeItem(atPath: fakeProject) }
|
||
|
||
try ProjectTemplateServiceTests.sampleDashboardJSON
|
||
.data(using: .utf8)!
|
||
.write(to: URL(fileURLWithPath: fakeProject + "/.scarf/dashboard.json"))
|
||
try "# Test project".data(using: .utf8)!
|
||
.write(to: URL(fileURLWithPath: fakeProject + "/README.md"))
|
||
try "# Agent notes".data(using: .utf8)!
|
||
.write(to: URL(fileURLWithPath: fakeProject + "/AGENTS.md"))
|
||
|
||
let entry = ProjectEntry(name: "Round Trip", path: fakeProject)
|
||
let exporter = ProjectTemplateExporter(context: .local)
|
||
let outputDir = try ProjectTemplateServiceTests.makeTempDir()
|
||
defer { try? FileManager.default.removeItem(atPath: outputDir) }
|
||
let outputPath = outputDir + "/rt.scarftemplate"
|
||
|
||
let inputs = ProjectTemplateExporter.ExportInputs(
|
||
project: entry,
|
||
templateId: "tester/round-trip",
|
||
templateName: "Round Trip",
|
||
templateVersion: "0.1.0",
|
||
description: "round-trip test",
|
||
authorName: "Tester",
|
||
authorUrl: nil,
|
||
category: nil,
|
||
tags: [],
|
||
includeSkillIds: [],
|
||
includeCronJobIds: [],
|
||
memoryAppendix: nil
|
||
)
|
||
|
||
try exporter.export(inputs: inputs, outputZipPath: outputPath)
|
||
#expect(FileManager.default.fileExists(atPath: outputPath))
|
||
|
||
let service = ProjectTemplateService(context: .local)
|
||
let inspection = try service.inspect(zipPath: outputPath)
|
||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||
#expect(inspection.manifest.id == "tester/round-trip")
|
||
#expect(inspection.files.contains("dashboard.json"))
|
||
#expect(inspection.files.contains("README.md"))
|
||
#expect(inspection.files.contains("AGENTS.md"))
|
||
}
|
||
}
|