using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.Tests.Infrastructure; using Xunit; namespace ClaudeDo.Worker.Tests.Skills; public sealed class SessionSkillRegistryTests : IDisposable { private readonly DbFixture _db = new(); private readonly string _skillsRoot; private readonly string _workDir; private readonly FakeRepoCloner _cloner = new(); public SessionSkillRegistryTests() { _workDir = Path.Combine(Path.GetTempPath(), $"claudedo_skill_test_{Guid.NewGuid():N}"); _skillsRoot = Path.Combine(_workDir, "session-skills"); Directory.CreateDirectory(_skillsRoot); } public void Dispose() { _db.Dispose(); try { Directory.Delete(_workDir, recursive: true); } catch { /* best effort */ } } private SessionSkillRegistry CreateRegistry() => new(_db.CreateFactory(), _cloner, _skillsRoot); private string NewFixtureDir([System.Runtime.CompilerServices.CallerMemberName] string name = "") => Path.Combine(_workDir, "fixtures", $"{name}_{Guid.NewGuid():N}"); [Fact] public async Task InstallAsync_multi_skill_layout_creates_one_row_and_dir_per_skill() { var fixtureDir = SkillFixtureBuilder.MultiSkillRepo(NewFixtureDir()); _cloner.Register("https://example.com/multi.git", fixtureDir, "sha-multi"); var registry = CreateRegistry(); var installed = await registry.InstallAsync("https://example.com/multi.git", default); Assert.Equal(new[] { "ponytail", "ponytail-help" }, installed.OrderBy(x => x).ToArray()); var rows = await registry.ListAsync(default); Assert.Equal(2, rows.Count); var ponytail = rows.Single(r => r.Name == "ponytail"); Assert.Equal("https://example.com/multi.git", ponytail.SourceUrl); Assert.Equal("sha-multi", ponytail.PinnedRef); Assert.Equal("skills/ponytail", ponytail.Subpath); Assert.Equal("Ties hair into a ponytail. Works on long hair only.", ponytail.Description); Assert.True(File.Exists(Path.Combine(_skillsRoot, "ponytail", "SKILL.md"))); var help = rows.Single(r => r.Name == "ponytail-help"); Assert.Equal("skills/ponytail-help", help.Subpath); Assert.Equal("Help text for the ponytail skill.", help.Description); Assert.True(File.Exists(Path.Combine(_skillsRoot, "ponytail-help", "SKILL.md"))); } [Fact] public async Task InstallAsync_root_skill_md_creates_single_row_with_dot_subpath() { var fixtureDir = SkillFixtureBuilder.RootSkillRepo(NewFixtureDir()); _cloner.Register("https://example.com/solo.git", fixtureDir, "sha-solo"); var registry = CreateRegistry(); var installed = await registry.InstallAsync("https://example.com/solo.git", default); Assert.Equal(new[] { "solo-skill" }, installed); var rows = await registry.ListAsync(default); var row = Assert.Single(rows); Assert.Equal("solo-skill", row.Name); Assert.Equal(".", row.Subpath); Assert.Equal("A single root-level skill.", row.Description); Assert.True(File.Exists(Path.Combine(_skillsRoot, "solo-skill", "SKILL.md"))); } [Fact] public async Task InstallAsync_no_skills_found_throws() { var fixtureDir = SkillFixtureBuilder.EmptyRepo(NewFixtureDir()); _cloner.Register("https://example.com/empty.git", fixtureDir, "sha-empty"); var registry = CreateRegistry(); await Assert.ThrowsAsync( () => registry.InstallAsync("https://example.com/empty.git", default)); } [Fact] public async Task InstallAsync_cross_source_name_collision_throws() { var fixtureA = SkillFixtureBuilder.RootSkillRepo(NewFixtureDir()); _cloner.Register("https://example.com/a.git", fixtureA, "sha-a"); var registry = CreateRegistry(); await registry.InstallAsync("https://example.com/a.git", default); // Different source URL, same skill name ("solo-skill"). var fixtureB = SkillFixtureBuilder.RootSkillRepo(NewFixtureDir()); _cloner.Register("https://example.com/b.git", fixtureB, "sha-b"); await Assert.ThrowsAsync( () => registry.InstallAsync("https://example.com/b.git", default)); // Original row must be untouched. var rows = await registry.ListAsync(default); var row = Assert.Single(rows); Assert.Equal("https://example.com/a.git", row.SourceUrl); Assert.Equal("sha-a", row.PinnedRef); } [Fact] public async Task InstallAsync_reinstalling_same_url_refreshes_pinned_ref_without_collision() { var fixtureDir = SkillFixtureBuilder.RootSkillRepo(NewFixtureDir()); _cloner.Register("https://example.com/solo.git", fixtureDir, "sha-1"); var registry = CreateRegistry(); await registry.InstallAsync("https://example.com/solo.git", default); _cloner.Register("https://example.com/solo.git", fixtureDir, "sha-2"); var installed = await registry.InstallAsync("https://example.com/solo.git", default); Assert.Equal(new[] { "solo-skill" }, installed); var rows = await registry.ListAsync(default); var row = Assert.Single(rows); Assert.Equal("sha-2", row.PinnedRef); } [Fact] public async Task RemoveAsync_deletes_all_dirs_and_rows_for_source() { var fixtureDir = SkillFixtureBuilder.MultiSkillRepo(NewFixtureDir()); _cloner.Register("https://example.com/multi.git", fixtureDir, "sha-multi"); var registry = CreateRegistry(); await registry.InstallAsync("https://example.com/multi.git", default); await registry.RemoveAsync("https://example.com/multi.git", default); Assert.Empty(await registry.ListAsync(default)); Assert.False(Directory.Exists(Path.Combine(_skillsRoot, "ponytail"))); Assert.False(Directory.Exists(Path.Combine(_skillsRoot, "ponytail-help"))); } [Fact] public async Task UpdateAsync_removes_stale_skill_when_source_drops_it() { var multiFixture = SkillFixtureBuilder.MultiSkillRepo(NewFixtureDir()); _cloner.Register("https://example.com/multi.git", multiFixture, "sha-1"); var registry = CreateRegistry(); await registry.InstallAsync("https://example.com/multi.git", default); // Source now only provides "ponytail" (drops "ponytail-help"). var reducedFixtureDir = NewFixtureDir(); Directory.CreateDirectory(Path.Combine(reducedFixtureDir, "skills")); CopyDir(Path.Combine(multiFixture, "skills", "ponytail"), Path.Combine(reducedFixtureDir, "skills", "ponytail")); _cloner.Register("https://example.com/multi.git", reducedFixtureDir, "sha-2"); await registry.UpdateAsync("https://example.com/multi.git", default); var rows = await registry.ListAsync(default); var row = Assert.Single(rows); Assert.Equal("ponytail", row.Name); Assert.Equal("sha-2", row.PinnedRef); Assert.True(Directory.Exists(Path.Combine(_skillsRoot, "ponytail"))); Assert.False(Directory.Exists(Path.Combine(_skillsRoot, "ponytail-help"))); } private static void CopyDir(string sourceDir, string destDir) { Directory.CreateDirectory(destDir); foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) { var relative = Path.GetRelativePath(sourceDir, file); var destPath = Path.Combine(destDir, relative); var destParent = Path.GetDirectoryName(destPath); if (!string.IsNullOrEmpty(destParent)) Directory.CreateDirectory(destParent); File.Copy(file, destPath, overwrite: true); } } }