feat(worker): session skill registry (install/update/remove, pinned clone)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using ClaudeDo.Worker.Skills;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Skills;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IRepoCloner: "clones" by copying a pre-built local fixture directory,
|
||||
/// so tests never touch the network or invoke real git. One fixture dir + pinned ref can
|
||||
/// be registered per source URL.
|
||||
/// </summary>
|
||||
public sealed class FakeRepoCloner : IRepoCloner
|
||||
{
|
||||
private readonly Dictionary<string, (string FixtureDir, string PinnedRef)> _sources = new();
|
||||
private int _cloneCount;
|
||||
|
||||
public int CloneCount => _cloneCount;
|
||||
|
||||
public void Register(string url, string fixtureDir, string pinnedRef) =>
|
||||
_sources[url] = (fixtureDir, pinnedRef);
|
||||
|
||||
public Task<ClonedRepo> CloneAsync(string url, string destDir, CancellationToken ct)
|
||||
{
|
||||
_cloneCount++;
|
||||
if (!_sources.TryGetValue(url, out var source))
|
||||
throw new InvalidOperationException($"FakeRepoCloner has no fixture registered for '{url}'.");
|
||||
|
||||
CopyDirectory(source.FixtureDir, destDir);
|
||||
return Task.FromResult(new ClonedRepo(destDir, source.PinnedRef));
|
||||
}
|
||||
|
||||
private static void CopyDirectory(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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<InvalidOperationException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace ClaudeDo.Worker.Tests.Skills;
|
||||
|
||||
/// <summary>Builds local fixture directories mirroring skill-repo layouts for FakeRepoCloner.</summary>
|
||||
public static class SkillFixtureBuilder
|
||||
{
|
||||
public static string MultiSkillRepo(string rootDir)
|
||||
{
|
||||
WriteSkillMd(Path.Combine(rootDir, "skills", "ponytail"), """
|
||||
---
|
||||
name: ponytail
|
||||
description: >
|
||||
Ties hair into a ponytail.
|
||||
Works on long hair only.
|
||||
---
|
||||
# Ponytail skill body
|
||||
""");
|
||||
|
||||
WriteSkillMd(Path.Combine(rootDir, "skills", "ponytail-help"), """
|
||||
---
|
||||
name: ponytail-help
|
||||
description: Help text for the ponytail skill.
|
||||
---
|
||||
# Ponytail help body
|
||||
""");
|
||||
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
public static string RootSkillRepo(string rootDir)
|
||||
{
|
||||
WriteSkillMd(rootDir, """
|
||||
---
|
||||
name: solo-skill
|
||||
description: A single root-level skill.
|
||||
---
|
||||
# Solo skill body
|
||||
""");
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
public static string EmptyRepo(string rootDir)
|
||||
{
|
||||
Directory.CreateDirectory(rootDir);
|
||||
File.WriteAllText(Path.Combine(rootDir, "README.md"), "nothing to see here");
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
private static void WriteSkillMd(string dir, string content)
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(Path.Combine(dir, "SKILL.md"), content);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user