From 597137f4b6f3b6375480e48b55c8bf658f67aa3b Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sat, 21 Mar 2026 15:46:25 +0100 Subject: [PATCH 1/8] [#121] Make DoctorRunner and checks accept injectable Environment - Add `environment` property to DoctorRunner and 10 check structs (CommandCheck, MCPServerCheck, PluginCheck, HookCheck, GitignoreCheck, ProjectIndexCheck, PackGitignoreCheck, ExternalCommandExistsCheck, ExternalHookEventExistsCheck, ExternalSettingsKeyEqualsCheck), replacing all hardcoded `Environment()` calls - Add `projectRootOverride` to DoctorRunner to bypass ProjectDetector.findProjectRoot() in tests - Flow environment through DerivedDoctorChecks and standaloneDoctorChecks --- Sources/mcs/Doctor/DoctorRunner.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index 33f8994..e78815e 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -173,7 +173,7 @@ struct DoctorRunner { // Layers 3-5: Standalone and project-scoped checks (scope-independent) var nonComponentChecks: [any DoctorCheck] = [] - nonComponentChecks += standaloneDoctorChecks() + nonComponentChecks += standaloneDoctorChecks(environment: env) if !globalOnly, let root = projectRoot { // Only add project-scoped checks if mcs was used in this project let claudeLocalExists = FileManager.default.fileExists( @@ -486,7 +486,7 @@ struct DoctorRunner { // MARK: - Standalone checks (not tied to any component) /// Checks that cannot be derived from any ComponentDefinition. - private func standaloneDoctorChecks() -> [any DoctorCheck] { + private func standaloneDoctorChecks(environment: Environment) -> [any DoctorCheck] { var checks: [any DoctorCheck] = [] // Gitignore (core entries) From cc0b20069ef8c35e12e1a2d0305481942e0ac264 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sat, 21 Mar 2026 15:56:43 +0100 Subject: [PATCH 2/8] [#121] Fix environment injection gaps in doctor checks - Thread environment from DoctorCommand to DoctorRunner - Pass environment to PackGitignoreCheck in artifactChecks() - Remove redundant environment parameter from private standaloneDoctorChecks() --- Sources/mcs/Doctor/DoctorRunner.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index e78815e..33f8994 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -173,7 +173,7 @@ struct DoctorRunner { // Layers 3-5: Standalone and project-scoped checks (scope-independent) var nonComponentChecks: [any DoctorCheck] = [] - nonComponentChecks += standaloneDoctorChecks(environment: env) + nonComponentChecks += standaloneDoctorChecks() if !globalOnly, let root = projectRoot { // Only add project-scoped checks if mcs was used in this project let claudeLocalExists = FileManager.default.fileExists( @@ -486,7 +486,7 @@ struct DoctorRunner { // MARK: - Standalone checks (not tied to any component) /// Checks that cannot be derived from any ComponentDefinition. - private func standaloneDoctorChecks(environment: Environment) -> [any DoctorCheck] { + private func standaloneDoctorChecks() -> [any DoctorCheck] { var checks: [any DoctorCheck] = [] // Gitignore (core entries) From 987a9cc03a3c860ed70753b00844f67d8d03aa4e Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sat, 21 Mar 2026 15:46:25 +0100 Subject: [PATCH 3/8] [#121] Make DoctorRunner and checks accept injectable Environment - Add `environment` property to DoctorRunner and 10 check structs (CommandCheck, MCPServerCheck, PluginCheck, HookCheck, GitignoreCheck, ProjectIndexCheck, PackGitignoreCheck, ExternalCommandExistsCheck, ExternalHookEventExistsCheck, ExternalSettingsKeyEqualsCheck), replacing all hardcoded `Environment()` calls - Add `projectRootOverride` to DoctorRunner to bypass ProjectDetector.findProjectRoot() in tests - Flow environment through DerivedDoctorChecks and standaloneDoctorChecks --- Sources/mcs/Doctor/DoctorRunner.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index 33f8994..e78815e 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -173,7 +173,7 @@ struct DoctorRunner { // Layers 3-5: Standalone and project-scoped checks (scope-independent) var nonComponentChecks: [any DoctorCheck] = [] - nonComponentChecks += standaloneDoctorChecks() + nonComponentChecks += standaloneDoctorChecks(environment: env) if !globalOnly, let root = projectRoot { // Only add project-scoped checks if mcs was used in this project let claudeLocalExists = FileManager.default.fileExists( @@ -486,7 +486,7 @@ struct DoctorRunner { // MARK: - Standalone checks (not tied to any component) /// Checks that cannot be derived from any ComponentDefinition. - private func standaloneDoctorChecks() -> [any DoctorCheck] { + private func standaloneDoctorChecks(environment: Environment) -> [any DoctorCheck] { var checks: [any DoctorCheck] = [] // Gitignore (core entries) From d628a1ac8105d3f9919bcf8482f7692a1595c23c Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sat, 21 Mar 2026 15:56:43 +0100 Subject: [PATCH 4/8] [#121] Fix environment injection gaps in doctor checks - Thread environment from DoctorCommand to DoctorRunner - Pass environment to PackGitignoreCheck in artifactChecks() - Remove redundant environment parameter from private standaloneDoctorChecks() --- Sources/mcs/Doctor/DoctorRunner.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index e78815e..33f8994 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -173,7 +173,7 @@ struct DoctorRunner { // Layers 3-5: Standalone and project-scoped checks (scope-independent) var nonComponentChecks: [any DoctorCheck] = [] - nonComponentChecks += standaloneDoctorChecks(environment: env) + nonComponentChecks += standaloneDoctorChecks() if !globalOnly, let root = projectRoot { // Only add project-scoped checks if mcs was used in this project let claudeLocalExists = FileManager.default.fileExists( @@ -486,7 +486,7 @@ struct DoctorRunner { // MARK: - Standalone checks (not tied to any component) /// Checks that cannot be derived from any ComponentDefinition. - private func standaloneDoctorChecks(environment: Environment) -> [any DoctorCheck] { + private func standaloneDoctorChecks() -> [any DoctorCheck] { var checks: [any DoctorCheck] = [] // Gitignore (core entries) From d964a2ec082757eeafdcf62960606677d7ff1cf0 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sat, 21 Mar 2026 16:08:56 +0100 Subject: [PATCH 5/8] [#121] Add end-to-end lifecycle integration tests - Add 6 lifecycle scenarios covering the full sync -> doctor -> remove pipeline with sandbox isolation - Scenario 1: Single-pack configure/doctor/drift/fix/remove - Scenario 2: Multi-pack convergence with selective removal - Scenario 3: Pack update with template v1 -> v2 change - Scenario 4: Component exclusion and re-inclusion - Scenario 5: Global scope sync and doctor - Scenario 6: Stale artifact cleanup on pack update (A,B,C -> A,D) --- .../MCSTests/LifecycleIntegrationTests.swift | 638 ++++++++++++++++++ 1 file changed, 638 insertions(+) create mode 100644 Tests/MCSTests/LifecycleIntegrationTests.swift diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift new file mode 100644 index 0000000..9c60104 --- /dev/null +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -0,0 +1,638 @@ +import Foundation +@testable import mcs +import Testing + +// MARK: - Test Bed + +/// Reusable sandbox environment for lifecycle tests. +private struct LifecycleTestBed { + let home: URL + let project: URL + let env: Environment + let mockCLI: MockClaudeCLI + + init() throws { + home = FileManager.default.temporaryDirectory + .appendingPathComponent("mcs-lifecycle-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + // Create ~/.claude/ and ~/.mcs/ + try FileManager.default.createDirectory( + at: home.appendingPathComponent(".claude"), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: home.appendingPathComponent(".mcs"), + withIntermediateDirectories: true + ) + // Create project with .git/ and .claude/ + project = home.appendingPathComponent("test-project") + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git"), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".claude"), + withIntermediateDirectories: true + ) + env = Environment(home: home) + mockCLI = MockClaudeCLI() + } + + func cleanup() { + try? FileManager.default.removeItem(at: home) + } + + func makeConfigurator(registry: TechPackRegistry = TechPackRegistry()) -> Configurator { + Configurator( + environment: env, + output: CLIOutput(colorsEnabled: false), + shell: ShellRunner(environment: env), + registry: registry, + strategy: ProjectSyncStrategy(projectPath: project, environment: env), + claudeCLI: mockCLI + ) + } + + func makeDoctorRunner(registry: TechPackRegistry, packFilter: String? = nil) -> DoctorRunner { + DoctorRunner( + fixMode: false, + skipConfirmation: true, + packFilter: packFilter, + registry: registry, + environment: env, + projectRootOverride: project + ) + } + + func makeGlobalConfigurator(registry: TechPackRegistry = TechPackRegistry()) -> Configurator { + Configurator( + environment: env, + output: CLIOutput(colorsEnabled: false), + shell: ShellRunner(environment: env), + registry: registry, + strategy: GlobalSyncStrategy(environment: env), + claudeCLI: mockCLI + ) + } + + func makeGlobalDoctorRunner(registry: TechPackRegistry) -> DoctorRunner { + DoctorRunner( + fixMode: false, + skipConfirmation: true, + globalOnly: true, + registry: registry, + environment: env, + projectRootOverride: nil + ) + } + + /// Create a hook source file in a temp pack directory. + func makeHookSource(name: String, content: String = "#!/bin/bash\necho hook") throws -> URL { + let packDir = home.appendingPathComponent("pack-source/hooks") + try FileManager.default.createDirectory(at: packDir, withIntermediateDirectories: true) + let file = packDir.appendingPathComponent(name) + try content.write(to: file, atomically: true, encoding: .utf8) + return file + } + + /// Create a settings merge source file. + func makeSettingsSource(content: String) throws -> URL { + let file = home.appendingPathComponent("pack-source/settings-\(UUID().uuidString).json") + let dir = file.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try content.write(to: file, atomically: true, encoding: .utf8) + return file + } + + /// Create a skill source file in a temp pack directory. + func makeSkillSource(name: String, content: String = "# Skill\nDo the thing.") throws -> URL { + let packDir = home.appendingPathComponent("pack-source/skills") + try FileManager.default.createDirectory(at: packDir, withIntermediateDirectories: true) + let file = packDir.appendingPathComponent(name) + try content.write(to: file, atomically: true, encoding: .utf8) + return file + } + + // MARK: - Assertions + + func projectState() throws -> ProjectState { + try ProjectState(projectRoot: project) + } + + var settingsLocalPath: URL { + project.appendingPathComponent(".claude/settings.local.json") + } + + var claudeLocalPath: URL { + project.appendingPathComponent("CLAUDE.local.md") + } +} + +// MARK: - Scenario 1: Single-Pack Lifecycle + +struct SinglePackLifecycleTests { + @Test("Full lifecycle: configure -> doctor pass -> drift -> doctor warn -> re-sync -> remove") + func fullSinglePackLifecycle() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + // Build a pack with hook + template + settings + let hookSource = try bed.makeHookSource(name: "lint.sh") + let settingsSource = try bed.makeSettingsSource(content: """ + { + "env": { "LINT_ENABLED": "true" } + } + """) + + let pack = MockTechPack( + identifier: "test-pack", + displayName: "Test Pack", + components: [ + ComponentDefinition( + id: "test-pack.lint-hook", + displayName: "Lint Hook", + description: "Post-tool lint hook", + type: .hookFile, + packIdentifier: "test-pack", + dependencies: [], + isRequired: true, + hookEvent: "PostToolUse", + installAction: .copyPackFile( + source: hookSource, + destination: "lint.sh", + fileType: .hook + ) + ), + ComponentDefinition( + id: "test-pack.settings", + displayName: "Settings", + description: "Pack settings", + type: .configuration, + packIdentifier: "test-pack", + dependencies: [], + isRequired: true, + installAction: .settingsMerge(source: settingsSource) + ), + ], + templates: [TemplateContribution( + sectionIdentifier: "test-pack", + templateContent: "## Test Pack\nLint all the things.", + placeholders: [] + )] + ) + let registry = TechPackRegistry(packs: [pack]) + + // === Step 1: Configure === + let configurator = bed.makeConfigurator(registry: registry) + try configurator.configure(packs: [pack], confirmRemovals: false) + + // Verify artifacts on disk + let hookFile = bed.project.appendingPathComponent(".claude/hooks/lint.sh") + #expect(FileManager.default.fileExists(atPath: hookFile.path)) + + let settingsData = try Data(contentsOf: bed.settingsLocalPath) + let settingsJSON = try #require(JSONSerialization.jsonObject(with: settingsData) as? [String: Any]) + let envDict = settingsJSON["env"] as? [String: Any] + #expect(envDict?["LINT_ENABLED"] as? String == "true") + + let claudeContent = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(claudeContent.contains("")) + #expect(claudeContent.contains("Lint all the things.")) + #expect(claudeContent.contains("")) + + // Verify state + let state = try bed.projectState() + #expect(state.configuredPacks.contains("test-pack")) + let artifacts = state.artifacts(for: "test-pack") + #expect(artifacts != nil) + #expect(artifacts?.templateSections.contains("test-pack") == true) + #expect(artifacts?.settingsKeys.contains("env") == true) + + // === Step 2: Doctor passes === + var runner = bed.makeDoctorRunner(registry: registry) + try runner.run() + + // === Step 3: Introduce settings drift === + var driftedSettings = settingsJSON + var driftedEnv = envDict ?? [:] + driftedEnv["LINT_ENABLED"] = "false" + driftedSettings["env"] = driftedEnv + let driftedData = try JSONSerialization.data(withJSONObject: driftedSettings, options: [.prettyPrinted, .sortedKeys]) + try driftedData.write(to: bed.settingsLocalPath) + + // === Step 4: Doctor detects drift === + var driftRunner = bed.makeDoctorRunner(registry: registry) + try driftRunner.run() + // (The runner completes — drift is reported as .warn, not a throw) + + // === Step 5: Re-sync fixes drift === + try configurator.configure(packs: [pack], confirmRemovals: false) + let fixedData = try Data(contentsOf: bed.settingsLocalPath) + let fixedJSON = try #require(JSONSerialization.jsonObject(with: fixedData) as? [String: Any]) + let fixedEnv = fixedJSON["env"] as? [String: Any] + #expect(fixedEnv?["LINT_ENABLED"] as? String == "true") + + // === Step 6: Remove the pack === + try configurator.configure(packs: [], confirmRemovals: false) + + // Verify settings cleaned up (empty packs → settings file removed or empty) + if FileManager.default.fileExists(atPath: bed.settingsLocalPath.path) { + let removedData = try Data(contentsOf: bed.settingsLocalPath) + let removedJSON = try JSONSerialization.jsonObject(with: removedData) as? [String: Any] ?? [:] + #expect(removedJSON["env"] == nil) + } + + // Template section should be removed from CLAUDE.local.md + if FileManager.default.fileExists(atPath: bed.claudeLocalPath.path) { + let removedContent = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(!removedContent.contains("")) + } + } +} + +// MARK: - Scenario 2: Multi-Pack Convergence + +struct MultiPackConvergenceTests { + @Test("Two packs compose correctly, selective removal cleans only one") + func twoPackConvergence() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let settingsA = try bed.makeSettingsSource(content: """ + { "env": { "PACK_A_KEY": "valueA" } } + """) + let settingsB = try bed.makeSettingsSource(content: """ + { "env": { "PACK_B_KEY": "valueB" } } + """) + + let packA = MockTechPack( + identifier: "pack-a", + displayName: "Pack A", + components: [ComponentDefinition( + id: "pack-a.settings", + displayName: "A Settings", + description: "Pack A settings", + type: .configuration, + packIdentifier: "pack-a", + dependencies: [], + isRequired: true, + installAction: .settingsMerge(source: settingsA) + )], + templates: [TemplateContribution( + sectionIdentifier: "pack-a", + templateContent: "## Pack A\nPack A content.", + placeholders: [] + )] + ) + let packB = MockTechPack( + identifier: "pack-b", + displayName: "Pack B", + components: [ComponentDefinition( + id: "pack-b.settings", + displayName: "B Settings", + description: "Pack B settings", + type: .configuration, + packIdentifier: "pack-b", + dependencies: [], + isRequired: true, + installAction: .settingsMerge(source: settingsB) + )], + templates: [TemplateContribution( + sectionIdentifier: "pack-b", + templateContent: "## Pack B\nPack B content.", + placeholders: [] + )] + ) + let registry = TechPackRegistry(packs: [packA, packB]) + let configurator = bed.makeConfigurator(registry: registry) + + // === Step 1: Configure both === + try configurator.configure(packs: [packA, packB], confirmRemovals: false) + + let settingsData = try Data(contentsOf: bed.settingsLocalPath) + let json = try #require(JSONSerialization.jsonObject(with: settingsData) as? [String: Any]) + let envDict = json["env"] as? [String: Any] + #expect(envDict?["PACK_A_KEY"] as? String == "valueA") + #expect(envDict?["PACK_B_KEY"] as? String == "valueB") + + let claudeContent = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(claudeContent.contains("")) + #expect(claudeContent.contains("")) + + // === Step 2: Doctor passes === + var runner = bed.makeDoctorRunner(registry: registry) + try runner.run() + + // === Step 3: Remove pack A only === + try configurator.configure(packs: [packB], confirmRemovals: false) + + let afterData = try Data(contentsOf: bed.settingsLocalPath) + let afterJSON = try #require(JSONSerialization.jsonObject(with: afterData) as? [String: Any]) + let afterEnv = afterJSON["env"] as? [String: Any] + #expect(afterEnv?["PACK_A_KEY"] == nil) + #expect(afterEnv?["PACK_B_KEY"] as? String == "valueB") + + let afterClaude = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(!afterClaude.contains("")) + #expect(afterClaude.contains("")) + + // State only has pack-b + let state = try bed.projectState() + #expect(!state.configuredPacks.contains("pack-a")) + #expect(state.configuredPacks.contains("pack-b")) + + // === Step 4: Re-add pack A === + try configurator.configure(packs: [packA, packB], confirmRemovals: false) + + let restoredData = try Data(contentsOf: bed.settingsLocalPath) + let restoredJSON = try #require(JSONSerialization.jsonObject(with: restoredData) as? [String: Any]) + let restoredEnv = restoredJSON["env"] as? [String: Any] + #expect(restoredEnv?["PACK_A_KEY"] as? String == "valueA") + #expect(restoredEnv?["PACK_B_KEY"] as? String == "valueB") + } +} + +// MARK: - Scenario 3: Pack Update with Template Change + +struct PackUpdateTemplateTests { + @Test("Template v1 -> v2: doctor detects, re-sync fixes") + func templateUpdateDetectedByDoctor() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let packV1 = MockTechPack( + identifier: "my-pack", + displayName: "My Pack", + templates: [TemplateContribution( + sectionIdentifier: "my-pack", + templateContent: "## My Pack v1\nVersion 1 content.", + placeholders: [] + )] + ) + let registry = TechPackRegistry(packs: [packV1]) + let configurator = bed.makeConfigurator(registry: registry) + + // === Step 1: Configure with v1 === + try configurator.configure(packs: [packV1], confirmRemovals: false) + + let content = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(content.contains("Version 1 content.")) + + // === Step 2: Doctor passes with v1 === + var runner = bed.makeDoctorRunner(registry: registry) + try runner.run() + + // === Step 3: Create v2 pack and re-configure === + let packV2 = MockTechPack( + identifier: "my-pack", + displayName: "My Pack", + templates: [TemplateContribution( + sectionIdentifier: "my-pack", + templateContent: "## My Pack v2\nVersion 2 content.", + placeholders: [] + )] + ) + let registryV2 = TechPackRegistry(packs: [packV2]) + let configuratorV2 = bed.makeConfigurator(registry: registryV2) + try configuratorV2.configure(packs: [packV2], confirmRemovals: false) + + // Verify content updated + let updatedContent = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(updatedContent.contains("Version 2 content.")) + #expect(!updatedContent.contains("Version 1 content.")) + + // === Step 4: Doctor passes with v2 === + var runnerV2 = bed.makeDoctorRunner(registry: registryV2) + try runnerV2.run() + } +} + +// MARK: - Scenario 4: Component Exclusion Lifecycle + +struct ComponentExclusionLifecycleTests { + @Test("Exclude component removes its artifacts, re-include restores them") + func excludeAndReinclude() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let hookA = try bed.makeHookSource(name: "hookA.sh", content: "#!/bin/bash\necho A") + let hookB = try bed.makeHookSource(name: "hookB.sh", content: "#!/bin/bash\necho B") + + let pack = MockTechPack( + identifier: "my-pack", + displayName: "My Pack", + components: [ + ComponentDefinition( + id: "my-pack.hookA", + displayName: "Hook A", + description: "First hook", + type: .hookFile, + packIdentifier: "my-pack", + dependencies: [], + isRequired: false, + installAction: .copyPackFile(source: hookA, destination: "hookA.sh", fileType: .hook) + ), + ComponentDefinition( + id: "my-pack.hookB", + displayName: "Hook B", + description: "Second hook", + type: .hookFile, + packIdentifier: "my-pack", + dependencies: [], + isRequired: false, + installAction: .copyPackFile(source: hookB, destination: "hookB.sh", fileType: .hook) + ), + ] + ) + let registry = TechPackRegistry(packs: [pack]) + let configurator = bed.makeConfigurator(registry: registry) + + let hookAPath = bed.project.appendingPathComponent(".claude/hooks/hookA.sh") + let hookBPath = bed.project.appendingPathComponent(".claude/hooks/hookB.sh") + + // === Step 1: Configure with both === + try configurator.configure(packs: [pack], confirmRemovals: false) + #expect(FileManager.default.fileExists(atPath: hookAPath.path)) + #expect(FileManager.default.fileExists(atPath: hookBPath.path)) + + // === Step 2: Reconfigure with hookA excluded === + try configurator.configure( + packs: [pack], + confirmRemovals: false, + excludedComponents: ["my-pack": Set(["my-pack.hookA"])] + ) + #expect(!FileManager.default.fileExists(atPath: hookAPath.path)) + #expect(FileManager.default.fileExists(atPath: hookBPath.path)) + + // Verify exclusion recorded in state + let state = try bed.projectState() + let excluded = state.excludedComponents(for: "my-pack") + #expect(excluded.contains("my-pack.hookA")) + + // === Step 3: Re-include all === + try configurator.configure(packs: [pack], confirmRemovals: false) + #expect(FileManager.default.fileExists(atPath: hookAPath.path)) + #expect(FileManager.default.fileExists(atPath: hookBPath.path)) + } +} + +// MARK: - Scenario 5: Global Scope Sync + Doctor + +struct GlobalScopeLifecycleTests { + @Test("Global scope sync installs artifacts and doctor passes") + func globalSyncAndDoctor() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let hookSource = try bed.makeHookSource(name: "global-hook.sh") + + let pack = MockTechPack( + identifier: "global-pack", + displayName: "Global Pack", + components: [ComponentDefinition( + id: "global-pack.hook", + displayName: "Global Hook", + description: "A global hook", + type: .hookFile, + packIdentifier: "global-pack", + dependencies: [], + isRequired: true, + installAction: .copyPackFile( + source: hookSource, + destination: "global-hook.sh", + fileType: .hook + ) + )] + ) + let registry = TechPackRegistry(packs: [pack]) + + // === Configure global scope === + let configurator = bed.makeGlobalConfigurator(registry: registry) + try configurator.configure(packs: [pack], confirmRemovals: false) + + // Verify hook installed in ~/.claude/hooks/ + let globalHook = bed.env.hooksDirectory.appendingPathComponent("global-hook.sh") + #expect(FileManager.default.fileExists(atPath: globalHook.path)) + + // Verify global state + let globalState = try ProjectState(stateFile: bed.env.globalStateFile) + #expect(globalState.configuredPacks.contains("global-pack")) + + // === Doctor passes === + var runner = bed.makeGlobalDoctorRunner(registry: registry) + try runner.run() + } +} + +// MARK: - Scenario 6: Stale Artifact Cleanup on Pack Update + +struct StaleArtifactCleanupTests { + @Test("v1 has A,B,C -> v2 removes B renames C->D: stale artifacts cleaned") + func staleArtifactCleanup() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let skillA = try bed.makeSkillSource(name: "skillA.md", content: "# Skill A") + let skillB = try bed.makeSkillSource(name: "skillB.md", content: "# Skill B") + let skillC = try bed.makeSkillSource(name: "skillC.md", content: "# Skill C") + + let packV1 = MockTechPack( + identifier: "my-pack", + displayName: "My Pack", + components: [ + ComponentDefinition( + id: "my-pack.skillA", + displayName: "Skill A", + description: "First skill", + type: .skill, + packIdentifier: "my-pack", + dependencies: [], + isRequired: true, + installAction: .copyPackFile(source: skillA, destination: "skillA.md", fileType: .skill) + ), + ComponentDefinition( + id: "my-pack.skillB", + displayName: "Skill B", + description: "Second skill", + type: .skill, + packIdentifier: "my-pack", + dependencies: [], + isRequired: true, + installAction: .copyPackFile(source: skillB, destination: "skillB.md", fileType: .skill) + ), + ComponentDefinition( + id: "my-pack.skillC", + displayName: "Skill C", + description: "Third skill", + type: .skill, + packIdentifier: "my-pack", + dependencies: [], + isRequired: true, + installAction: .copyPackFile(source: skillC, destination: "skillC.md", fileType: .skill) + ), + ] + ) + let registryV1 = TechPackRegistry(packs: [packV1]) + let configuratorV1 = bed.makeConfigurator(registry: registryV1) + + // === Configure with v1 === + try configuratorV1.configure(packs: [packV1], confirmRemovals: false) + + let skillsDir = bed.project.appendingPathComponent(".claude/skills") + #expect(FileManager.default.fileExists(atPath: skillsDir.appendingPathComponent("skillA.md").path)) + #expect(FileManager.default.fileExists(atPath: skillsDir.appendingPathComponent("skillB.md").path)) + #expect(FileManager.default.fileExists(atPath: skillsDir.appendingPathComponent("skillC.md").path)) + + // === Create v2: remove B, add D (C->D rename) === + let skillD = try bed.makeSkillSource(name: "skillD.md", content: "# Skill D (was C)") + let packV2 = MockTechPack( + identifier: "my-pack", + displayName: "My Pack", + components: [ + ComponentDefinition( + id: "my-pack.skillA", + displayName: "Skill A", + description: "First skill", + type: .skill, + packIdentifier: "my-pack", + dependencies: [], + isRequired: true, + installAction: .copyPackFile(source: skillA, destination: "skillA.md", fileType: .skill) + ), + ComponentDefinition( + id: "my-pack.skillD", + displayName: "Skill D", + description: "Fourth skill (replaced C)", + type: .skill, + packIdentifier: "my-pack", + dependencies: [], + isRequired: true, + installAction: .copyPackFile(source: skillD, destination: "skillD.md", fileType: .skill) + ), + ] + ) + let registryV2 = TechPackRegistry(packs: [packV2]) + let configuratorV2 = bed.makeConfigurator(registry: registryV2) + + // === Configure with v2 === + try configuratorV2.configure(packs: [packV2], confirmRemovals: false) + + // A still exists, B removed, C removed, D created + #expect(FileManager.default.fileExists(atPath: skillsDir.appendingPathComponent("skillA.md").path)) + #expect(!FileManager.default.fileExists(atPath: skillsDir.appendingPathComponent("skillB.md").path)) + #expect(!FileManager.default.fileExists(atPath: skillsDir.appendingPathComponent("skillC.md").path)) + #expect(FileManager.default.fileExists(atPath: skillsDir.appendingPathComponent("skillD.md").path)) + + // Artifact record only tracks A and D + let state = try bed.projectState() + let artifacts = try #require(state.artifacts(for: "my-pack")) + #expect(artifacts.files.contains { $0.contains("skillA.md") }) + #expect(artifacts.files.contains { $0.contains("skillD.md") }) + #expect(!artifacts.files.contains { $0.contains("skillB.md") }) + #expect(!artifacts.files.contains { $0.contains("skillC.md") }) + + // === Doctor passes === + var runner = bed.makeDoctorRunner(registry: registryV2) + try runner.run() + } +} From cff116ca09fb5b7793fb0bc82c51d111ba1a4cfd Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sat, 21 Mar 2026 16:19:22 +0100 Subject: [PATCH 6/8] Add MCP verification, template dependencies, global exclusion, and fix tests - Add MCP server registration/removal assertions to scenario 1 - Add hook settings auto-derive verification to scenario 1 - Scenario 7: Template dependency filtering (exclude component removes dependent template sections, re-include restores) - Scenario 8: Global scope exclusion + doctor - Scenario 9: Doctor fix restores tampered section content via re-sync --- .../MCSTests/LifecycleIntegrationTests.swift | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index 9c60104..814f5aa 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -163,6 +163,21 @@ struct SinglePackLifecycleTests { fileType: .hook ) ), + ComponentDefinition( + id: "test-pack.mcp-server", + displayName: "Test MCP", + description: "A test MCP server", + type: .mcpServer, + packIdentifier: "test-pack", + dependencies: [], + isRequired: true, + installAction: .mcpServer(MCPServerConfig( + name: "test-mcp", + command: "npx", + args: ["-y", "test-server"], + env: ["API_KEY": "test-key"] + )) + ), ComponentDefinition( id: "test-pack.settings", displayName: "Settings", @@ -200,6 +215,15 @@ struct SinglePackLifecycleTests { #expect(claudeContent.contains("Lint all the things.")) #expect(claudeContent.contains("")) + // Verify hook command auto-derived into settings + let settings = try Settings.load(from: bed.settingsLocalPath) + let postToolGroups = settings.hooks?["PostToolUse"] ?? [] + let hookCommands = postToolGroups.flatMap { $0.hooks ?? [] }.compactMap(\.command) + #expect(hookCommands.contains("bash .claude/hooks/lint.sh")) + + // Verify MCP server was registered via MockClaudeCLI + #expect(bed.mockCLI.mcpAddCalls.contains { $0.name == "test-mcp" }) + // Verify state let state = try bed.projectState() #expect(state.configuredPacks.contains("test-pack")) @@ -207,6 +231,8 @@ struct SinglePackLifecycleTests { #expect(artifacts != nil) #expect(artifacts?.templateSections.contains("test-pack") == true) #expect(artifacts?.settingsKeys.contains("env") == true) + #expect(artifacts?.hookCommands.contains("bash .claude/hooks/lint.sh") == true) + #expect(artifacts?.mcpServers.contains { $0.name == "test-mcp" } == true) // === Step 2: Doctor passes === var runner = bed.makeDoctorRunner(registry: registry) @@ -235,6 +261,9 @@ struct SinglePackLifecycleTests { // === Step 6: Remove the pack === try configurator.configure(packs: [], confirmRemovals: false) + // Verify MCP server was removed via MockClaudeCLI + #expect(bed.mockCLI.mcpRemoveCalls.contains { $0.name == "test-mcp" }) + // Verify settings cleaned up (empty packs → settings file removed or empty) if FileManager.default.fileExists(atPath: bed.settingsLocalPath.path) { let removedData = try Data(contentsOf: bed.settingsLocalPath) @@ -636,3 +665,213 @@ struct StaleArtifactCleanupTests { try runner.run() } } + +// MARK: - Scenario 7: Template Dependency Filtering + +struct TemplateDependencyFilteringTests { + @Test("Excluding a component removes its dependent template sections") + func excludedComponentFiltersDependentTemplate() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let hookSource = try bed.makeHookSource(name: "serena-hook.sh") + + let pack = MockTechPack( + identifier: "my-pack", + displayName: "My Pack", + components: [ + ComponentDefinition( + id: "my-pack.serena", + displayName: "Serena", + description: "Serena MCP server", + type: .mcpServer, + packIdentifier: "my-pack", + dependencies: [], + isRequired: false, + installAction: .mcpServer(MCPServerConfig( + name: "serena", command: "npx", + args: ["-y", "serena"], env: [:] + )) + ), + ComponentDefinition( + id: "my-pack.hook", + displayName: "Hook", + description: "A hook", + type: .hookFile, + packIdentifier: "my-pack", + dependencies: [], + isRequired: true, + installAction: .copyPackFile( + source: hookSource, destination: "hook.sh", fileType: .hook + ) + ), + ], + templates: [ + TemplateContribution( + sectionIdentifier: "my-pack", + templateContent: "## My Pack\nGeneral instructions.", + placeholders: [] + ), + TemplateContribution( + sectionIdentifier: "my-pack-serena", + templateContent: "## Serena Instructions\nUse Serena for code editing.", + placeholders: [], + dependencies: ["my-pack.serena"] + ), + ] + ) + let registry = TechPackRegistry(packs: [pack]) + let configurator = bed.makeConfigurator(registry: registry) + + // === Step 1: Configure with all components === + try configurator.configure(packs: [pack], confirmRemovals: false) + + let content = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(content.contains("")) + #expect(content.contains("")) + #expect(content.contains("Use Serena for code editing.")) + + // === Step 2: Exclude Serena → dependent template removed === + try configurator.configure( + packs: [pack], + confirmRemovals: false, + excludedComponents: ["my-pack": Set(["my-pack.serena"])] + ) + + let afterContent = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(afterContent.contains("")) + #expect(afterContent.contains("General instructions.")) + // Serena-dependent template section should be removed + #expect(!afterContent.contains("")) + #expect(!afterContent.contains("Use Serena for code editing.")) + + // MCP server should have been removed + #expect(bed.mockCLI.mcpRemoveCalls.contains { $0.name == "serena" }) + + // === Step 3: Re-include → both templates restored === + try configurator.configure(packs: [pack], confirmRemovals: false) + + let restoredContent = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(restoredContent.contains("")) + #expect(restoredContent.contains("Use Serena for code editing.")) + } +} + +// MARK: - Scenario 8: Global Scope Exclusion + Doctor + +struct GlobalScopeExclusionTests { + @Test("Global scope exclusion recorded and doctor skips excluded checks") + func globalExclusionAndDoctor() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let hookA = try bed.makeHookSource(name: "globalA.sh") + let hookB = try bed.makeHookSource(name: "globalB.sh") + + let pack = MockTechPack( + identifier: "global-pack", + displayName: "Global Pack", + components: [ + ComponentDefinition( + id: "global-pack.hookA", + displayName: "Global Hook A", + description: "First global hook", + type: .hookFile, + packIdentifier: "global-pack", + dependencies: [], + isRequired: false, + installAction: .copyPackFile( + source: hookA, destination: "globalA.sh", fileType: .hook + ) + ), + ComponentDefinition( + id: "global-pack.hookB", + displayName: "Global Hook B", + description: "Second global hook", + type: .hookFile, + packIdentifier: "global-pack", + dependencies: [], + isRequired: false, + installAction: .copyPackFile( + source: hookB, destination: "globalB.sh", fileType: .hook + ) + ), + ] + ) + let registry = TechPackRegistry(packs: [pack]) + + // === Step 1: Configure global with both === + let configurator = bed.makeGlobalConfigurator(registry: registry) + try configurator.configure(packs: [pack], confirmRemovals: false) + + let hookAPath = bed.env.hooksDirectory.appendingPathComponent("globalA.sh") + let hookBPath = bed.env.hooksDirectory.appendingPathComponent("globalB.sh") + #expect(FileManager.default.fileExists(atPath: hookAPath.path)) + #expect(FileManager.default.fileExists(atPath: hookBPath.path)) + + // === Step 2: Reconfigure with hookA excluded === + try configurator.configure( + packs: [pack], + confirmRemovals: false, + excludedComponents: ["global-pack": Set(["global-pack.hookA"])] + ) + + #expect(!FileManager.default.fileExists(atPath: hookAPath.path)) + #expect(FileManager.default.fileExists(atPath: hookBPath.path)) + + // Verify exclusion in global state + let globalState = try ProjectState(stateFile: bed.env.globalStateFile) + let excluded = globalState.excludedComponents(for: "global-pack") + #expect(excluded.contains("global-pack.hookA")) + + // === Step 3: Doctor with globalOnly runs without error === + var runner = bed.makeGlobalDoctorRunner(registry: registry) + try runner.run() + } +} + +// MARK: - Scenario 9: Doctor Fix Restores Outdated Section Content + +struct DoctorFixSectionTests { + @Test("Doctor fix restores tampered section content") + func doctorFixRestoresSection() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let pack = MockTechPack( + identifier: "my-pack", + displayName: "My Pack", + templates: [TemplateContribution( + sectionIdentifier: "my-pack", + templateContent: "## My Pack\nOriginal content that should be preserved.", + placeholders: [] + )] + ) + let registry = TechPackRegistry(packs: [pack]) + let configurator = bed.makeConfigurator(registry: registry) + + // === Configure === + try configurator.configure(packs: [pack], confirmRemovals: false) + + let content = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(content.contains("Original content that should be preserved.")) + + // === Tamper with section content === + let tamperedContent = content.replacingOccurrences( + of: "Original content that should be preserved.", + with: "TAMPERED by user." + ) + try tamperedContent.write(to: bed.claudeLocalPath, atomically: true, encoding: .utf8) + + // Verify the tamper took effect + let readBack = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(readBack.contains("TAMPERED by user.")) + + // === Re-sync restores the original content === + try configurator.configure(packs: [pack], confirmRemovals: false) + + let restoredContent = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(restoredContent.contains("Original content that should be preserved.")) + #expect(!restoredContent.contains("TAMPERED by user.")) + } +} From fb49678baadd76019fe905bf838c81e838bda584 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sat, 21 Mar 2026 17:11:34 +0100 Subject: [PATCH 7/8] Use shared makeSandboxProject in LifecycleTestBed --- .../MCSTests/LifecycleIntegrationTests.swift | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index 814f5aa..b31593b 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -12,28 +12,7 @@ private struct LifecycleTestBed { let mockCLI: MockClaudeCLI init() throws { - home = FileManager.default.temporaryDirectory - .appendingPathComponent("mcs-lifecycle-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) - // Create ~/.claude/ and ~/.mcs/ - try FileManager.default.createDirectory( - at: home.appendingPathComponent(".claude"), - withIntermediateDirectories: true - ) - try FileManager.default.createDirectory( - at: home.appendingPathComponent(".mcs"), - withIntermediateDirectories: true - ) - // Create project with .git/ and .claude/ - project = home.appendingPathComponent("test-project") - try FileManager.default.createDirectory( - at: project.appendingPathComponent(".git"), - withIntermediateDirectories: true - ) - try FileManager.default.createDirectory( - at: project.appendingPathComponent(".claude"), - withIntermediateDirectories: true - ) + (home, project) = try makeSandboxProject(label: "lifecycle") env = Environment(home: home) mockCLI = MockClaudeCLI() } From e18666a86633f9d81c698637e5dcec9906ba4e05 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sat, 21 Mar 2026 17:31:40 +0100 Subject: [PATCH 8/8] Apply review suggestions: factories, helpers, and naming fixes - Add component factory methods (hookComponent, skillComponent, settingsComponent, mcpComponent) to LifecycleTestBed - Add runDoctor/runGlobalDoctor convenience methods - Add settingsEnv() helper to reduce JSON assertion boilerplate - Add MCP scope assertion (local) in Scenario 1 - Rename Scenario 9 from "Doctor fix" to "Re-sync restores" to accurately reflect that re-sync (not doctor --fix) restores content --- .../MCSTests/LifecycleIntegrationTests.swift | 354 ++++++------------ 1 file changed, 124 insertions(+), 230 deletions(-) diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index b31593b..90b0380 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -92,12 +92,96 @@ private struct LifecycleTestBed { return file } + // MARK: - Doctor Convenience + + func runDoctor(registry: TechPackRegistry, packFilter: String? = nil) throws { + var runner = makeDoctorRunner(registry: registry, packFilter: packFilter) + try runner.run() + } + + func runGlobalDoctor(registry: TechPackRegistry) throws { + var runner = makeGlobalDoctorRunner(registry: registry) + try runner.run() + } + + // MARK: - Component Factories + + func hookComponent( + pack: String, id: String, source: URL, destination: String, + hookEvent: String? = nil, isRequired: Bool = true + ) -> ComponentDefinition { + ComponentDefinition( + id: "\(pack).\(id)", + displayName: id, + description: "Hook \(id)", + type: .hookFile, + packIdentifier: pack, + dependencies: [], + isRequired: isRequired, + hookEvent: hookEvent, + installAction: .copyPackFile(source: source, destination: destination, fileType: .hook) + ) + } + + func skillComponent( + pack: String, id: String, source: URL, destination: String + ) -> ComponentDefinition { + ComponentDefinition( + id: "\(pack).\(id)", + displayName: id, + description: "Skill \(id)", + type: .skill, + packIdentifier: pack, + dependencies: [], + isRequired: true, + installAction: .copyPackFile(source: source, destination: destination, fileType: .skill) + ) + } + + func settingsComponent(pack: String, id: String, source: URL) -> ComponentDefinition { + ComponentDefinition( + id: "\(pack).\(id)", + displayName: id, + description: "Settings \(id)", + type: .configuration, + packIdentifier: pack, + dependencies: [], + isRequired: true, + installAction: .settingsMerge(source: source) + ) + } + + func mcpComponent( + pack: String, id: String, name: String, + command: String = "npx", args: [String] = [], env: [String: String] = [:], + isRequired: Bool = true + ) -> ComponentDefinition { + ComponentDefinition( + id: "\(pack).\(id)", + displayName: id, + description: "MCP \(id)", + type: .mcpServer, + packIdentifier: pack, + dependencies: [], + isRequired: isRequired, + installAction: .mcpServer(MCPServerConfig( + name: name, command: command, args: args, env: env + )) + ) + } + // MARK: - Assertions func projectState() throws -> ProjectState { try ProjectState(projectRoot: project) } + func settingsEnv() throws -> [String: Any] { + let data = try Data(contentsOf: settingsLocalPath) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + return json["env"] as? [String: Any] ?? [:] + } + var settingsLocalPath: URL { project.appendingPathComponent(".claude/settings.local.json") } @@ -127,46 +211,9 @@ struct SinglePackLifecycleTests { identifier: "test-pack", displayName: "Test Pack", components: [ - ComponentDefinition( - id: "test-pack.lint-hook", - displayName: "Lint Hook", - description: "Post-tool lint hook", - type: .hookFile, - packIdentifier: "test-pack", - dependencies: [], - isRequired: true, - hookEvent: "PostToolUse", - installAction: .copyPackFile( - source: hookSource, - destination: "lint.sh", - fileType: .hook - ) - ), - ComponentDefinition( - id: "test-pack.mcp-server", - displayName: "Test MCP", - description: "A test MCP server", - type: .mcpServer, - packIdentifier: "test-pack", - dependencies: [], - isRequired: true, - installAction: .mcpServer(MCPServerConfig( - name: "test-mcp", - command: "npx", - args: ["-y", "test-server"], - env: ["API_KEY": "test-key"] - )) - ), - ComponentDefinition( - id: "test-pack.settings", - displayName: "Settings", - description: "Pack settings", - type: .configuration, - packIdentifier: "test-pack", - dependencies: [], - isRequired: true, - installAction: .settingsMerge(source: settingsSource) - ), + bed.hookComponent(pack: "test-pack", id: "lint-hook", source: hookSource, destination: "lint.sh", hookEvent: "PostToolUse"), + bed.mcpComponent(pack: "test-pack", id: "mcp-server", name: "test-mcp", args: ["-y", "test-server"], env: ["API_KEY": "test-key"]), + bed.settingsComponent(pack: "test-pack", id: "settings", source: settingsSource), ], templates: [TemplateContribution( sectionIdentifier: "test-pack", @@ -200,8 +247,8 @@ struct SinglePackLifecycleTests { let hookCommands = postToolGroups.flatMap { $0.hooks ?? [] }.compactMap(\.command) #expect(hookCommands.contains("bash .claude/hooks/lint.sh")) - // Verify MCP server was registered via MockClaudeCLI - #expect(bed.mockCLI.mcpAddCalls.contains { $0.name == "test-mcp" }) + // Verify MCP server was registered via MockClaudeCLI with local scope + #expect(bed.mockCLI.mcpAddCalls.contains { $0.name == "test-mcp" && $0.scope == "local" }) // Verify state let state = try bed.projectState() @@ -214,8 +261,7 @@ struct SinglePackLifecycleTests { #expect(artifacts?.mcpServers.contains { $0.name == "test-mcp" } == true) // === Step 2: Doctor passes === - var runner = bed.makeDoctorRunner(registry: registry) - try runner.run() + try bed.runDoctor(registry: registry) // === Step 3: Introduce settings drift === var driftedSettings = settingsJSON @@ -226,8 +272,7 @@ struct SinglePackLifecycleTests { try driftedData.write(to: bed.settingsLocalPath) // === Step 4: Doctor detects drift === - var driftRunner = bed.makeDoctorRunner(registry: registry) - try driftRunner.run() + try bed.runDoctor(registry: registry) // (The runner completes — drift is reported as .warn, not a throw) // === Step 5: Re-sync fixes drift === @@ -276,16 +321,7 @@ struct MultiPackConvergenceTests { let packA = MockTechPack( identifier: "pack-a", displayName: "Pack A", - components: [ComponentDefinition( - id: "pack-a.settings", - displayName: "A Settings", - description: "Pack A settings", - type: .configuration, - packIdentifier: "pack-a", - dependencies: [], - isRequired: true, - installAction: .settingsMerge(source: settingsA) - )], + components: [bed.settingsComponent(pack: "pack-a", id: "settings", source: settingsA)], templates: [TemplateContribution( sectionIdentifier: "pack-a", templateContent: "## Pack A\nPack A content.", @@ -295,16 +331,7 @@ struct MultiPackConvergenceTests { let packB = MockTechPack( identifier: "pack-b", displayName: "Pack B", - components: [ComponentDefinition( - id: "pack-b.settings", - displayName: "B Settings", - description: "Pack B settings", - type: .configuration, - packIdentifier: "pack-b", - dependencies: [], - isRequired: true, - installAction: .settingsMerge(source: settingsB) - )], + components: [bed.settingsComponent(pack: "pack-b", id: "settings", source: settingsB)], templates: [TemplateContribution( sectionIdentifier: "pack-b", templateContent: "## Pack B\nPack B content.", @@ -317,28 +344,23 @@ struct MultiPackConvergenceTests { // === Step 1: Configure both === try configurator.configure(packs: [packA, packB], confirmRemovals: false) - let settingsData = try Data(contentsOf: bed.settingsLocalPath) - let json = try #require(JSONSerialization.jsonObject(with: settingsData) as? [String: Any]) - let envDict = json["env"] as? [String: Any] - #expect(envDict?["PACK_A_KEY"] as? String == "valueA") - #expect(envDict?["PACK_B_KEY"] as? String == "valueB") + let envDict = try bed.settingsEnv() + #expect(envDict["PACK_A_KEY"] as? String == "valueA") + #expect(envDict["PACK_B_KEY"] as? String == "valueB") let claudeContent = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) #expect(claudeContent.contains("")) #expect(claudeContent.contains("")) // === Step 2: Doctor passes === - var runner = bed.makeDoctorRunner(registry: registry) - try runner.run() + try bed.runDoctor(registry: registry) // === Step 3: Remove pack A only === try configurator.configure(packs: [packB], confirmRemovals: false) - let afterData = try Data(contentsOf: bed.settingsLocalPath) - let afterJSON = try #require(JSONSerialization.jsonObject(with: afterData) as? [String: Any]) - let afterEnv = afterJSON["env"] as? [String: Any] - #expect(afterEnv?["PACK_A_KEY"] == nil) - #expect(afterEnv?["PACK_B_KEY"] as? String == "valueB") + let afterEnv = try bed.settingsEnv() + #expect(afterEnv["PACK_A_KEY"] == nil) + #expect(afterEnv["PACK_B_KEY"] as? String == "valueB") let afterClaude = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) #expect(!afterClaude.contains("")) @@ -352,11 +374,9 @@ struct MultiPackConvergenceTests { // === Step 4: Re-add pack A === try configurator.configure(packs: [packA, packB], confirmRemovals: false) - let restoredData = try Data(contentsOf: bed.settingsLocalPath) - let restoredJSON = try #require(JSONSerialization.jsonObject(with: restoredData) as? [String: Any]) - let restoredEnv = restoredJSON["env"] as? [String: Any] - #expect(restoredEnv?["PACK_A_KEY"] as? String == "valueA") - #expect(restoredEnv?["PACK_B_KEY"] as? String == "valueB") + let restoredEnv = try bed.settingsEnv() + #expect(restoredEnv["PACK_A_KEY"] as? String == "valueA") + #expect(restoredEnv["PACK_B_KEY"] as? String == "valueB") } } @@ -387,8 +407,7 @@ struct PackUpdateTemplateTests { #expect(content.contains("Version 1 content.")) // === Step 2: Doctor passes with v1 === - var runner = bed.makeDoctorRunner(registry: registry) - try runner.run() + try bed.runDoctor(registry: registry) // === Step 3: Create v2 pack and re-configure === let packV2 = MockTechPack( @@ -410,8 +429,7 @@ struct PackUpdateTemplateTests { #expect(!updatedContent.contains("Version 1 content.")) // === Step 4: Doctor passes with v2 === - var runnerV2 = bed.makeDoctorRunner(registry: registryV2) - try runnerV2.run() + try bed.runDoctor(registry: registryV2) } } @@ -430,26 +448,8 @@ struct ComponentExclusionLifecycleTests { identifier: "my-pack", displayName: "My Pack", components: [ - ComponentDefinition( - id: "my-pack.hookA", - displayName: "Hook A", - description: "First hook", - type: .hookFile, - packIdentifier: "my-pack", - dependencies: [], - isRequired: false, - installAction: .copyPackFile(source: hookA, destination: "hookA.sh", fileType: .hook) - ), - ComponentDefinition( - id: "my-pack.hookB", - displayName: "Hook B", - description: "Second hook", - type: .hookFile, - packIdentifier: "my-pack", - dependencies: [], - isRequired: false, - installAction: .copyPackFile(source: hookB, destination: "hookB.sh", fileType: .hook) - ), + bed.hookComponent(pack: "my-pack", id: "hookA", source: hookA, destination: "hookA.sh", isRequired: false), + bed.hookComponent(pack: "my-pack", id: "hookB", source: hookB, destination: "hookB.sh", isRequired: false), ] ) let registry = TechPackRegistry(packs: [pack]) @@ -497,20 +497,7 @@ struct GlobalScopeLifecycleTests { let pack = MockTechPack( identifier: "global-pack", displayName: "Global Pack", - components: [ComponentDefinition( - id: "global-pack.hook", - displayName: "Global Hook", - description: "A global hook", - type: .hookFile, - packIdentifier: "global-pack", - dependencies: [], - isRequired: true, - installAction: .copyPackFile( - source: hookSource, - destination: "global-hook.sh", - fileType: .hook - ) - )] + components: [bed.hookComponent(pack: "global-pack", id: "hook", source: hookSource, destination: "global-hook.sh")] ) let registry = TechPackRegistry(packs: [pack]) @@ -527,8 +514,7 @@ struct GlobalScopeLifecycleTests { #expect(globalState.configuredPacks.contains("global-pack")) // === Doctor passes === - var runner = bed.makeGlobalDoctorRunner(registry: registry) - try runner.run() + try bed.runGlobalDoctor(registry: registry) } } @@ -548,36 +534,9 @@ struct StaleArtifactCleanupTests { identifier: "my-pack", displayName: "My Pack", components: [ - ComponentDefinition( - id: "my-pack.skillA", - displayName: "Skill A", - description: "First skill", - type: .skill, - packIdentifier: "my-pack", - dependencies: [], - isRequired: true, - installAction: .copyPackFile(source: skillA, destination: "skillA.md", fileType: .skill) - ), - ComponentDefinition( - id: "my-pack.skillB", - displayName: "Skill B", - description: "Second skill", - type: .skill, - packIdentifier: "my-pack", - dependencies: [], - isRequired: true, - installAction: .copyPackFile(source: skillB, destination: "skillB.md", fileType: .skill) - ), - ComponentDefinition( - id: "my-pack.skillC", - displayName: "Skill C", - description: "Third skill", - type: .skill, - packIdentifier: "my-pack", - dependencies: [], - isRequired: true, - installAction: .copyPackFile(source: skillC, destination: "skillC.md", fileType: .skill) - ), + bed.skillComponent(pack: "my-pack", id: "skillA", source: skillA, destination: "skillA.md"), + bed.skillComponent(pack: "my-pack", id: "skillB", source: skillB, destination: "skillB.md"), + bed.skillComponent(pack: "my-pack", id: "skillC", source: skillC, destination: "skillC.md"), ] ) let registryV1 = TechPackRegistry(packs: [packV1]) @@ -597,26 +556,8 @@ struct StaleArtifactCleanupTests { identifier: "my-pack", displayName: "My Pack", components: [ - ComponentDefinition( - id: "my-pack.skillA", - displayName: "Skill A", - description: "First skill", - type: .skill, - packIdentifier: "my-pack", - dependencies: [], - isRequired: true, - installAction: .copyPackFile(source: skillA, destination: "skillA.md", fileType: .skill) - ), - ComponentDefinition( - id: "my-pack.skillD", - displayName: "Skill D", - description: "Fourth skill (replaced C)", - type: .skill, - packIdentifier: "my-pack", - dependencies: [], - isRequired: true, - installAction: .copyPackFile(source: skillD, destination: "skillD.md", fileType: .skill) - ), + bed.skillComponent(pack: "my-pack", id: "skillA", source: skillA, destination: "skillA.md"), + bed.skillComponent(pack: "my-pack", id: "skillD", source: skillD, destination: "skillD.md"), ] ) let registryV2 = TechPackRegistry(packs: [packV2]) @@ -640,8 +581,7 @@ struct StaleArtifactCleanupTests { #expect(!artifacts.files.contains { $0.contains("skillC.md") }) // === Doctor passes === - var runner = bed.makeDoctorRunner(registry: registryV2) - try runner.run() + try bed.runDoctor(registry: registryV2) } } @@ -659,31 +599,8 @@ struct TemplateDependencyFilteringTests { identifier: "my-pack", displayName: "My Pack", components: [ - ComponentDefinition( - id: "my-pack.serena", - displayName: "Serena", - description: "Serena MCP server", - type: .mcpServer, - packIdentifier: "my-pack", - dependencies: [], - isRequired: false, - installAction: .mcpServer(MCPServerConfig( - name: "serena", command: "npx", - args: ["-y", "serena"], env: [:] - )) - ), - ComponentDefinition( - id: "my-pack.hook", - displayName: "Hook", - description: "A hook", - type: .hookFile, - packIdentifier: "my-pack", - dependencies: [], - isRequired: true, - installAction: .copyPackFile( - source: hookSource, destination: "hook.sh", fileType: .hook - ) - ), + bed.mcpComponent(pack: "my-pack", id: "serena", name: "serena", args: ["-y", "serena"], isRequired: false), + bed.hookComponent(pack: "my-pack", id: "hook", source: hookSource, destination: "hook.sh"), ], templates: [ TemplateContribution( @@ -751,30 +668,8 @@ struct GlobalScopeExclusionTests { identifier: "global-pack", displayName: "Global Pack", components: [ - ComponentDefinition( - id: "global-pack.hookA", - displayName: "Global Hook A", - description: "First global hook", - type: .hookFile, - packIdentifier: "global-pack", - dependencies: [], - isRequired: false, - installAction: .copyPackFile( - source: hookA, destination: "globalA.sh", fileType: .hook - ) - ), - ComponentDefinition( - id: "global-pack.hookB", - displayName: "Global Hook B", - description: "Second global hook", - type: .hookFile, - packIdentifier: "global-pack", - dependencies: [], - isRequired: false, - installAction: .copyPackFile( - source: hookB, destination: "globalB.sh", fileType: .hook - ) - ), + bed.hookComponent(pack: "global-pack", id: "hookA", source: hookA, destination: "globalA.sh", isRequired: false), + bed.hookComponent(pack: "global-pack", id: "hookB", source: hookB, destination: "globalB.sh", isRequired: false), ] ) let registry = TechPackRegistry(packs: [pack]) @@ -804,16 +699,15 @@ struct GlobalScopeExclusionTests { #expect(excluded.contains("global-pack.hookA")) // === Step 3: Doctor with globalOnly runs without error === - var runner = bed.makeGlobalDoctorRunner(registry: registry) - try runner.run() + try bed.runGlobalDoctor(registry: registry) } } -// MARK: - Scenario 9: Doctor Fix Restores Outdated Section Content +// MARK: - Scenario 9: Re-sync Restores Tampered Section Content -struct DoctorFixSectionTests { - @Test("Doctor fix restores tampered section content") - func doctorFixRestoresSection() throws { +struct SectionRestorationTests { + @Test("Re-sync restores tampered section content") + func reSyncRestoresTamperedSection() throws { let bed = try LifecycleTestBed() defer { bed.cleanup() }