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 `/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///` /// 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/` — // 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///.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")) } }