File: Agents\LocalAspireSkillsTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Cli.Agents;
using Aspire.Cli.Agents.AspireSkills;
 
namespace Aspire.Cli.Tests.Agents;
 
public class LocalAspireSkillsTests(ITestOutputHelper output) : IDisposable
{
    private readonly AgentConfigurationTestContext _context = new(output);
 
    [Fact]
    public async Task Find_RecognizesLegacySkillsWithoutCreatingABundleOrCache()
    {
        string[] names =
        [
            "aspire",
            "aspire-deployment",
            "aspire-init",
            "aspire-monitoring",
            "aspire-orchestration",
            "aspire-project-v2-migration",
            "aspireify"
        ];
        var paths = names.Select(name => Path.Combine(_context.Project.FullName, ".agents", "skills", name, "SKILL.md")).ToArray();
        foreach (var path in paths)
        {
            await AgentConfigurationTestContext.WriteAsync(path, "local skill");
        }
        var before = Directory.GetFileSystemEntries(_context.Workspace.Path, "*", SearchOption.AllDirectories).Order().ToArray();
        Assert.False(Directory.Exists(_context.ExecutionContext.CacheDirectory.FullName));
 
        var result = Scan([_context.Copilot]);
 
        Assert.Empty(result.Errors);
        Assert.Equal(paths.Order(StringComparers.FileSystemPath), result.Files);
        Assert.Equal(before, Directory.GetFileSystemEntries(_context.Workspace.Path, "*", SearchOption.AllDirectories).Order());
    }
 
    [Theory]
    [InlineData("GitHub Copilot", ".github")]
    [InlineData("GitHub Copilot", ".agents")]
    [InlineData("GitHub Copilot", ".claude")]
    [InlineData("Claude Code", ".claude")]
    [InlineData("OpenCode", ".agents")]
    public async Task Find_ReportsKnownNamesWithoutInferringOwnershipOrModifyingFiles(string agent, string directory)
    {
        var path = Path.Combine(_context.Project.FullName, directory, "skills", "aspire", "SKILL.md");
        const string content = "---\nname: aspire\n---\nMy customized instructions.";
        await AgentConfigurationTestContext.WriteAsync(path, content);
        var empty = Path.Combine(_context.Project.FullName, directory, "skills", "aspire-init", "SKILL.md");
        await AgentConfigurationTestContext.WriteAsync(empty, string.Empty);
        var timestamp = File.GetLastWriteTimeUtc(path);
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(_context.Project.FullName, directory, "skills", "aspire-custom", "SKILL.md"), "unrelated");
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(_context.Project.FullName, directory, "skills", "playwright-cli", "SKILL.md"), "unrelated");
        var before = Directory.GetFileSystemEntries(_context.Workspace.Path, "*", SearchOption.AllDirectories).Order().ToArray();
 
        var result = Scan([_context.Clients.Single(scanner => scanner.DisplayName == agent)]);
 
        Assert.Empty(result.Errors);
        Assert.Equal(new[] { path, empty }.Order(StringComparers.FileSystemPath), result.Files);
        Assert.Equal(content, await File.ReadAllTextAsync(path));
        Assert.Empty(await File.ReadAllTextAsync(empty));
        Assert.Equal(timestamp, File.GetLastWriteTimeUtc(path));
        Assert.Equal(before, Directory.GetFileSystemEntries(_context.Workspace.Path, "*", SearchOption.AllDirectories).Order());
    }
 
    [Fact]
    public async Task Find_ChecksBothScopesAndDeduplicatesSharedLocations()
    {
        var project = Path.Combine(_context.Project.FullName, ".agents", "skills", "aspire-init", "SKILL.md");
        var user = Path.Combine(_context.Home.FullName, ".agents", "skills", "aspireify", "SKILL.md");
        await AgentConfigurationTestContext.WriteAsync(project, "project");
        await AgentConfigurationTestContext.WriteAsync(user, "user");
 
        var result = Scan([_context.Copilot, _context.OpenCode]);
 
        Assert.Empty(result.Errors);
        Assert.Equal(new[] { project, user }.Order(StringComparers.FileSystemPath), result.Files);
    }
 
    [Fact]
    public async Task Find_OnlyUsesTheSelectedAgentsLocationsAndHonorsOverrides()
    {
        var custom = Path.Combine(_context.Home.FullName, "claude-work");
        _context.SetVariable("CLAUDE_CONFIG_DIR", custom);
        var selected = Path.Combine(custom, "skills", "aspire-deployment", "SKILL.md");
        await AgentConfigurationTestContext.WriteAsync(selected, "customized");
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(_context.Home.FullName, ".claude", "skills", "aspire", "SKILL.md"), "old home");
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(_context.Project.FullName, ".github", "skills", "aspire", "SKILL.md"), "other agent");
 
        var result = Scan([_context.ClaudeCode]);
 
        Assert.Empty(result.Errors);
        Assert.Equal(selected, Assert.Single(result.Files));
    }
 
    [Fact]
    public async Task Find_RecognizesLegacyOpenCodeAndCustomUserLocations()
    {
        var custom = _context.Workspace.CreateDirectory("custom-opencode");
        _context.SetVariable("OPENCODE_CONFIG_DIR", custom.FullName);
        var paths = new[]
        {
            Path.Combine(_context.Project.FullName, ".opencode", "skill", "aspire", "SKILL.md"),
            Path.Combine(custom.FullName, "skills", "aspire-orchestration", "SKILL.md")
        };
        foreach (var path in paths)
        {
            await AgentConfigurationTestContext.WriteAsync(path, "local");
        }
 
        var result = Scan([_context.OpenCode]);
 
        Assert.Empty(result.Errors);
        Assert.Equal(paths.Order(StringComparers.FileSystemPath), result.Files);
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task Find_RecognizesConfiguredOpenCodeSkillRootsWithoutChangingFiles(bool array, bool global)
    {
        var configPath = global
            ? Path.Combine(_context.OpenCodeDirectory, "opencode.jsonc")
            : Path.Combine(_context.Project.FullName, ".opencode", "opencode.jsonc");
        var paths = new[]
        {
            Path.Combine(_context.Project.FullName, "team-skills", "aspire", "SKILL.md"),
            Path.Combine(_context.Home.FullName, "shared-skills", "aspireify", "SKILL.md")
        };
        var config = array
            ? """{"skills":["./team-skills","~/shared-skills","https://example.test/skills/"]}"""
            : """{"skills":{"paths":["./team-skills","~/shared-skills"],"urls":["https://example.test/skills/"]}}""";
        await AgentConfigurationTestContext.WriteAsync(configPath, config);
        foreach (var path in paths)
        {
            await AgentConfigurationTestContext.WriteAsync(path, "local");
        }
 
        var result = Scan([_context.OpenCode]);
 
        Assert.Empty(result.Errors);
        Assert.Equal(paths.Order(StringComparers.FileSystemPath), result.Files);
        Assert.Equal(config, await File.ReadAllTextAsync(configPath));
        foreach (var path in paths)
        {
            Assert.Equal("local", await File.ReadAllTextAsync(path));
        }
    }
 
    [Theory]
    [InlineData("{broken")]
    [InlineData("""{"skills":{"paths":[true]}}""")]
    [InlineData("""{"skills":[false]}""")]
    public async Task Find_ReportsInvalidOpenCodeSkillConfiguration(string config)
    {
        var path = Path.Combine(_context.Project.FullName, "opencode.json");
        await AgentConfigurationTestContext.WriteAsync(path, config);
 
        var result = Scan([_context.OpenCode]);
 
        Assert.Empty(result.Files);
        Assert.Contains("OpenCode", Assert.Single(result.Errors));
        Assert.False(result.HasFileErrors);
        Assert.Equal(config, await File.ReadAllTextAsync(path));
    }
 
    [Fact]
    public async Task Find_DoesNotReadPluginCachesOrNestedUnrelatedProjects()
    {
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(_context.CopilotDirectory, "installed-plugins", "aspire", "skills", "aspire", "SKILL.md"), "installed");
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(_context.Project.FullName, "unrelated", ".agents", "skills", "aspire", "SKILL.md"), "unrelated");
 
        var result = Scan(_context.Clients);
 
        Assert.Empty(result.Files);
        Assert.Empty(result.Errors);
    }
 
    [Theory]
    [InlineData("GitHub Copilot", ".github")]
    [InlineData("GitHub Copilot", ".claude")]
    [InlineData("Claude Code", ".claude")]
    [InlineData("OpenCode", ".opencode")]
    [InlineData("OpenCode", ".claude")]
    [InlineData("OpenCode", ".agents")]
    public async Task Find_InspectsOnlyActiveAncestorsWithinTheWorkspace(string agent, string directory)
    {
        var parent = _context.Project.CreateSubdirectory("apps");
        var working = parent.CreateSubdirectory("active");
        var paths = new[] { _context.Project, parent, working }
            .Select(root => Path.Combine(root.FullName, directory, "skills", "aspire", "SKILL.md")).ToArray();
        foreach (var path in paths)
        {
            await AgentConfigurationTestContext.WriteAsync(path, "active local skill");
        }
        await AgentConfigurationTestContext.WriteAsync(
            Path.Combine(parent.FullName, "sibling", directory, "skills", "aspire", "SKILL.md"), "unrelated");
        await AgentConfigurationTestContext.WriteAsync(
            Path.Combine(_context.Workspace.Path, directory, "skills", "aspire", "SKILL.md"), "outside the workspace");
        var before = Directory.GetFileSystemEntries(_context.Workspace.Path, "*", SearchOption.AllDirectories).Order().ToArray();
 
        var result = LocalAspireSkills.Find(working, _context.Project,
            [_context.Clients.Single(scanner => scanner.DisplayName == agent)], TestContext.Current.CancellationToken);
 
        Assert.Empty(result.Errors);
        Assert.Equal(paths.Order(StringComparers.FileSystemPath), result.Files);
        Assert.Equal(before, Directory.GetFileSystemEntries(_context.Workspace.Path, "*", SearchOption.AllDirectories).Order());
    }
 
    [Fact]
    public async Task Find_OutsideWorkingDirectoryUsesOnlyTheSelectedWorkspace()
    {
        var outside = _context.Workspace.CreateDirectory("outside");
        var selected = Path.Combine(_context.Project.FullName, ".agents", "skills", "aspire", "SKILL.md");
        await AgentConfigurationTestContext.WriteAsync(selected, "selected");
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(outside.FullName, ".agents", "skills", "aspire", "SKILL.md"), "outside");
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(_context.Workspace.Path, ".agents", "skills", "aspire", "SKILL.md"), "ancestor");
 
        var result = LocalAspireSkills.Find(outside, _context.Project, [_context.OpenCode],
            TestContext.Current.CancellationToken);
 
        Assert.Empty(result.Errors);
        Assert.Equal(selected, Assert.Single(result.Files));
    }
 
    [Fact]
    public async Task Find_ReportsInspectionErrorsRatherThanClaimingNoConflicts()
    {
        var path = Path.Combine(_context.Project.FullName, ".agents", "skills", "aspire", "SKILL.md");
        Directory.CreateDirectory(path);
        await AgentConfigurationTestContext.WriteAsync(Path.Combine(_context.Project.FullName, ".agents", "skills", "aspireify", "SKILL.md"), "readable");
 
        var result = Scan([_context.Copilot]);
 
        Assert.Single(result.Files);
        Assert.Contains(path, Assert.Single(result.Errors));
        Assert.True(result.HasFileErrors);
        Assert.True(Directory.Exists(path));
    }
 
    [Fact]
    public void Find_CancellationPropagates()
    {
        Assert.ThrowsAny<OperationCanceledException>(() =>
            LocalAspireSkills.Find(_context.Project, _context.Project, _context.Clients, new CancellationToken(canceled: true)));
        Assert.Empty(_context.Project.EnumerateFileSystemInfos());
        Assert.Empty(_context.Home.EnumerateFileSystemInfos());
    }
 
    private (IReadOnlyList<string> Files, IReadOnlyList<string> Errors, bool HasFileErrors) Scan(IEnumerable<IAgentClient> agents)
        => LocalAspireSkills.Find(_context.Project, _context.Project, agents, TestContext.Current.CancellationToken);
 
    public void Dispose() => _context.Dispose();
}