mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
806075ae61
* Fix YAML block scalar parsing for file skills * Address block scalar parsing review feedback
1237 lines
50 KiB
C#
1237 lines
50 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
||
|
||
using System;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Threading.Tasks;
|
||
|
||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||
|
||
/// <summary>
|
||
/// Unit tests for the <see cref="AgentFileSkillsSource"/> skill discovery and parsing logic.
|
||
/// </summary>
|
||
public sealed class FileAgentSkillLoaderTests : IDisposable
|
||
{
|
||
private static readonly string[] s_customExtensions = [".custom"];
|
||
private static readonly string[] s_validExtensions = [".md", ".json", ".custom"];
|
||
private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"];
|
||
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
|
||
|
||
private readonly string _testRoot;
|
||
|
||
public FileAgentSkillLoaderTests()
|
||
{
|
||
this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N"));
|
||
Directory.CreateDirectory(this._testRoot);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (Directory.Exists(this._testRoot))
|
||
{
|
||
Directory.Delete(this._testRoot, recursive: true);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_ValidSkill_ReturnsSkillAsync()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("my-skill", skills[0].Frontmatter.Name);
|
||
Assert.Equal("A test skill", skills[0].Frontmatter.Description);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_QuotedFrontmatterValues_ParsesCorrectlyAsync()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "quoted-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("quoted-skill", skills[0].Frontmatter.Name);
|
||
Assert.Equal("A quoted description", skills[0].Frontmatter.Description);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_BlockScalarDescription_ParsesMultilineValueAsync()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "block-scalar-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: block-scalar-skill\ndescription: |\n This is a multiline\n description for the skill.\n---\nBody text.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("This is a multiline\ndescription for the skill.", skills[0].Frontmatter.Description);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_FoldedScalarDescription_ParsesMultilineValueAsync()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "folded-scalar-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: folded-scalar-skill\ndescription: >\n This is a multiline\n description for the skill.\n---\nBody text.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("This is a multiline description for the skill.", skills[0].Frontmatter.Description);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("|-", "This is a multiline\ndescription for the skill.")]
|
||
[InlineData("|+", "This is a multiline\ndescription for the skill.\n")]
|
||
[InlineData(">-", "This is a multiline description for the skill.")]
|
||
[InlineData(">+", "This is a multiline description for the skill.\n")]
|
||
public async Task GetSkillsAsync_ScalarDescriptionWithChompingIndicator_ParsesValueAsync(string indicator, string expectedDescription)
|
||
{
|
||
// Arrange
|
||
string chomping = indicator[1] == '+' ? "keep" : "strip";
|
||
string skillName = "chomping-scalar-skill-" + (indicator[0] == '|' ? "literal-" : "folded-") + chomping;
|
||
string skillDir = Path.Combine(this._testRoot, skillName);
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
$"---\nname: {skillName}\ndescription: {indicator}\n This is a multiline\n description for the skill.\n---\nBody text.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal(expectedDescription, skills[0].Frontmatter.Description);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "bad-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_MissingNameField_ExcludesSkillAsync()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "no-name");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\ndescription: A skill without a name\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_MissingDescriptionField_ExcludesSkillAsync()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "no-desc");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: no-desc\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("BadName")]
|
||
[InlineData("-leading-hyphen")]
|
||
[InlineData("trailing-hyphen-")]
|
||
[InlineData("has spaces")]
|
||
[InlineData("consecutive--hyphens")]
|
||
public async Task GetSkillsAsync_InvalidName_ExcludesSkillAsync(string invalidName)
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, invalidName);
|
||
if (Directory.Exists(skillDir))
|
||
{
|
||
Directory.Delete(skillDir, recursive: true);
|
||
}
|
||
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
$"---\nname: {invalidName}\ndescription: A skill\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_DuplicateNames_KeepsFirstOnlyAsync()
|
||
{
|
||
// Arrange
|
||
string dir1 = Path.Combine(this._testRoot, "dupe");
|
||
string dir2 = Path.Combine(this._testRoot, "subdir");
|
||
Directory.CreateDirectory(dir1);
|
||
Directory.CreateDirectory(dir2);
|
||
|
||
// Create a nested duplicate: subdir/dupe/SKILL.md
|
||
string nestedDir = Path.Combine(dir2, "dupe");
|
||
Directory.CreateDirectory(nestedDir);
|
||
File.WriteAllText(
|
||
Path.Combine(dir1, "SKILL.md"),
|
||
"---\nname: dupe\ndescription: First\n---\nFirst body.");
|
||
File.WriteAllText(
|
||
Path.Combine(nestedDir, "SKILL.md"),
|
||
"---\nname: dupe\ndescription: Second\n---\nSecond body.");
|
||
var fileSource = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
var source = new DeduplicatingAgentSkillsSource(fileSource);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert – filesystem enumeration order is not guaranteed, so we only
|
||
// verify that exactly one of the two duplicates was kept.
|
||
Assert.Single(skills);
|
||
string desc = skills[0].Frontmatter.Description;
|
||
Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync()
|
||
{
|
||
// Arrange — directory name differs from the frontmatter name
|
||
_ = this.CreateSkillDirectoryWithRawContent(
|
||
"wrong-dir-name",
|
||
"---\nname: actual-skill-name\ndescription: A skill\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync()
|
||
{
|
||
// Arrange — create resource files in spec-defined subdirectories
|
||
string skillDir = Path.Combine(this._testRoot, "resource-skill");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
string assetsDir = Path.Combine(skillDir, "assets");
|
||
Directory.CreateDirectory(refsDir);
|
||
Directory.CreateDirectory(assetsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
|
||
File.WriteAllText(Path.Combine(assetsDir, "data.json"), "{}");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
var skill = skills[0];
|
||
Assert.Equal(2, skill.Resources!.Count);
|
||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||
Assert.Contains(skill.Resources!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync()
|
||
{
|
||
// Arrange — create a file with an extension not in the default list inside a spec directory
|
||
string skillDir = Path.Combine(this._testRoot, "ext-skill");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "image.png"), "fake image");
|
||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: ext-skill\ndescription: Extension test\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
var skill = skills[0];
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Equal("references/data.json", skill.Resources![0].Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync()
|
||
{
|
||
// Arrange — the SKILL.md file itself should not be in the resource list
|
||
string skillDir = Path.Combine(this._testRoot, "selfref-skill");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: selfref-skill\ndescription: Self ref test\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
var skill = skills[0];
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Equal("references/notes.md", skill.Resources![0].Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync()
|
||
{
|
||
// Arrange — resource files directly in references/ are discovered; subdirectories are not scanned
|
||
string skillDir = Path.Combine(this._testRoot, "nested-res-skill");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "top.md"), "top content");
|
||
string deepDir = Path.Combine(refsDir, "level1", "level2");
|
||
Directory.CreateDirectory(deepDir);
|
||
File.WriteAllText(Path.Combine(deepDir, "deep.md"), "deep content");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — only the file directly in references/ is discovered; the nested file is not
|
||
Assert.Single(skills);
|
||
var skill = skills[0];
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
|
||
Assert.DoesNotContain(skill.Resources!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync()
|
||
{
|
||
// Arrange — use a source with custom extensions; files placed in spec directory
|
||
string skillDir = Path.Combine(this._testRoot, "custom-ext-skill");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "data.custom"), "custom data");
|
||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_customExtensions });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — only .custom files should be discovered, not .json
|
||
Assert.Single(skills);
|
||
var skill = skills[0];
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Equal("references/data.custom", skill.Resources![0].Name);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("txt")]
|
||
[InlineData("")]
|
||
[InlineData(" ")]
|
||
public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension)
|
||
{
|
||
// Arrange & Act & Assert
|
||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = new string[] { badExtension } }));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Constructor_NullExtensions_UsesDefaultsAsync()
|
||
{
|
||
// Arrange & Act
|
||
string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body.");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Assert — default extensions include .md
|
||
var skills = await source.GetSkillsAsync();
|
||
Assert.Single(skills[0].Resources!);
|
||
}
|
||
|
||
[Fact]
|
||
public void Constructor_ValidExtensions_DoesNotThrow()
|
||
{
|
||
// Arrange & Act & Assert — should not throw
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_validExtensions });
|
||
Assert.NotNull(source);
|
||
}
|
||
|
||
[Fact]
|
||
public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException()
|
||
{
|
||
// Arrange & Act & Assert — one bad extension in the list should cause failure
|
||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_mixedValidInvalidExtensions }));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
|
||
{
|
||
// Arrange — resource files directly in the skill directory (not in a spec subdirectory)
|
||
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
|
||
File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: root-resource-skill\ndescription: Root resources\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
|
||
Assert.Single(skills);
|
||
Assert.Empty(skills[0].Resources!);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
|
||
{
|
||
// Arrange — "." in ResourceDirectories opts into root-level resource discovery
|
||
string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
|
||
File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
|
||
Assert.Single(skills);
|
||
var skill = skills[0];
|
||
Assert.Equal(2, skill.Resources!.Count);
|
||
Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
|
||
Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync()
|
||
{
|
||
// Arrange — resource in a non-spec directory (neither references/ nor assets/)
|
||
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
|
||
string customDir = Path.Combine(skillDir, "docs");
|
||
Directory.CreateDirectory(customDir);
|
||
File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: non-spec-skill\ndescription: Non-spec directory\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — non-spec directories are not scanned by default
|
||
Assert.Single(skills);
|
||
Assert.Empty(skills[0].Resources!);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync()
|
||
{
|
||
// Arrange — custom ResourceDirectories replaces the spec defaults
|
||
string skillDir = Path.Combine(this._testRoot, "custom-directory-skill");
|
||
string customDir = Path.Combine(skillDir, "docs");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(customDir);
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
|
||
File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — only docs/ is scanned; references/ is NOT scanned
|
||
Assert.Single(skills);
|
||
var skill = skills[0];
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Equal("docs/readme.md", skill.Resources![0].Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync()
|
||
{
|
||
// Arrange — skill with no resource files
|
||
_ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Empty(skills[0].Resources!);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_EmptyPaths_ReturnsEmptyListAsync()
|
||
{
|
||
// Arrange
|
||
var source = new AgentFileSkillsSource(Enumerable.Empty<string>(), s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_NonExistentPath_ReturnsEmptyListAsync()
|
||
{
|
||
// Arrange
|
||
var source = new AgentFileSkillsSource(Path.Combine(this._testRoot, "does-not-exist"), s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimitAsync()
|
||
{
|
||
// Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1)
|
||
string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill");
|
||
Directory.CreateDirectory(nestedDir);
|
||
File.WriteAllText(
|
||
Path.Combine(nestedDir, "SKILL.md"),
|
||
"---\nname: nested-skill\ndescription: Nested\n---\nNested body.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("nested-skill", skills[0].Frontmatter.Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
|
||
{
|
||
// Arrange — create a skill with a resource file discovered from the references directory
|
||
string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details.");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
var skills = await source.GetSkillsAsync();
|
||
var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md");
|
||
|
||
// Act
|
||
var content = await resource.ReadAsync();
|
||
|
||
// Assert
|
||
Assert.Equal("Document content here.", content);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_NameExceedsMaxLength_ExcludesSkillAsync()
|
||
{
|
||
// Arrange — name longer than 64 characters
|
||
string longName = new('a', 65);
|
||
string skillDir = Path.Combine(this._testRoot, "long-name");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
$"---\nname: {longName}\ndescription: A skill\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync()
|
||
{
|
||
// Arrange — description longer than 1024 characters
|
||
string longDesc = new('x', 1025);
|
||
string skillDir = Path.Combine(this._testRoot, "long-desc");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
$"---\nname: long-desc\ndescription: {longDesc}\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
#if NET
|
||
[Fact]
|
||
public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync()
|
||
{
|
||
// Arrange — references/ is a symlink pointing outside the skill directory;
|
||
// a legitimate file lives in assets/ and should still be discovered.
|
||
string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
|
||
string assetsDir = Path.Combine(skillDir, "assets");
|
||
Directory.CreateDirectory(assetsDir);
|
||
File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
|
||
|
||
string outsideDir = Path.Combine(this._testRoot, "outside");
|
||
Directory.CreateDirectory(outsideDir);
|
||
File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
|
||
|
||
string refsLink = Path.Combine(skillDir, "references");
|
||
try
|
||
{
|
||
Directory.CreateSymbolicLink(refsLink, outsideDir);
|
||
}
|
||
catch (IOException)
|
||
{
|
||
// Symlink creation requires elevation on some platforms; skip gracefully.
|
||
return;
|
||
}
|
||
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — skill should still load, the symlinked references/ is skipped, assets/legit.md is found
|
||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
|
||
Assert.NotNull(skill);
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_SymlinkedResourceDirectory_SkipsWithoutEnumeratingAsync()
|
||
{
|
||
// Arrange — references/ is a symlink pointing outside the skill directory.
|
||
// The directory-level check should skip it entirely (no file enumeration),
|
||
// so even files with valid extensions in the target are not discovered.
|
||
string skillDir = Path.Combine(this._testRoot, "symlink-directory-skip");
|
||
string assetsDir = Path.Combine(skillDir, "assets");
|
||
Directory.CreateDirectory(assetsDir);
|
||
File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
|
||
|
||
string outsideDir = Path.Combine(this._testRoot, "outside-resources");
|
||
Directory.CreateDirectory(outsideDir);
|
||
File.WriteAllText(Path.Combine(outsideDir, "external.md"), "external content");
|
||
File.WriteAllText(Path.Combine(outsideDir, "data.json"), "{}");
|
||
|
||
string refsLink = Path.Combine(skillDir, "references");
|
||
try
|
||
{
|
||
Directory.CreateSymbolicLink(refsLink, outsideDir);
|
||
}
|
||
catch (IOException)
|
||
{
|
||
// Symlink creation requires elevation on some platforms; skip gracefully.
|
||
return;
|
||
}
|
||
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: symlink-directory-skip\ndescription: Symlinked directory skip\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely
|
||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip");
|
||
Assert.NotNull(skill);
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_SymlinkedScriptDirectory_SkipsWithoutEnumeratingAsync()
|
||
{
|
||
// Arrange — scripts/ is a symlink pointing outside the skill directory.
|
||
// The directory-level check should skip it entirely.
|
||
string skillDir = Path.Combine(this._testRoot, "symlink-script-skip");
|
||
Directory.CreateDirectory(skillDir);
|
||
|
||
string outsideDir = Path.Combine(this._testRoot, "outside-scripts");
|
||
Directory.CreateDirectory(outsideDir);
|
||
File.WriteAllText(Path.Combine(outsideDir, "malicious.py"), "import os; os.system('rm -rf /')");
|
||
|
||
string scriptsLink = Path.Combine(skillDir, "scripts");
|
||
try
|
||
{
|
||
Directory.CreateSymbolicLink(scriptsLink, outsideDir);
|
||
}
|
||
catch (IOException)
|
||
{
|
||
return;
|
||
}
|
||
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: symlink-script-skip\ndescription: Symlinked script directory\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — skill loads but scripts from the symlinked directory are not discovered
|
||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
|
||
Assert.NotNull(skill);
|
||
Assert.Empty(skill.Scripts!);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync()
|
||
{
|
||
// Arrange — custom resource directory "sub/resources" where "sub" is a symlink.
|
||
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
|
||
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
|
||
Directory.CreateDirectory(skillDir);
|
||
|
||
string outsideDir = Path.Combine(this._testRoot, "outside-intermediate");
|
||
string outsideResources = Path.Combine(outsideDir, "resources");
|
||
Directory.CreateDirectory(outsideResources);
|
||
File.WriteAllText(Path.Combine(outsideResources, "data.md"), "data");
|
||
|
||
string subLink = Path.Combine(skillDir, "sub");
|
||
try
|
||
{
|
||
Directory.CreateSymbolicLink(subLink, outsideDir);
|
||
}
|
||
catch (IOException)
|
||
{
|
||
return;
|
||
}
|
||
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: symlink-intermediate\ndescription: Intermediate symlink\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(
|
||
this._testRoot,
|
||
s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — the symlinked intermediate segment causes the directory to be skipped
|
||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
|
||
Assert.NotNull(skill);
|
||
Assert.Empty(skill.Resources!);
|
||
}
|
||
#endif
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync()
|
||
{
|
||
// Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter
|
||
_ = this.CreateSkillDirectoryWithRawContent(
|
||
"bom-skill",
|
||
"\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("bom-skill", skills[0].Frontmatter.Name);
|
||
Assert.Equal("Skill with BOM", skills[0].Frontmatter.Description);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_LicenseField_ParsedCorrectlyAsync()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectoryWithRawContent(
|
||
"licensed-skill",
|
||
"---\nname: licensed-skill\ndescription: A skill with license\nlicense: MIT\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("MIT", skills[0].Frontmatter.License);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_CompatibilityField_ParsedCorrectlyAsync()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectoryWithRawContent(
|
||
"compat-skill",
|
||
"---\nname: compat-skill\ndescription: A skill with compatibility\ncompatibility: Requires Node.js 18+\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("Requires Node.js 18+", skills[0].Frontmatter.Compatibility);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectoryWithRawContent(
|
||
"tools-skill",
|
||
"---\nname: tools-skill\ndescription: A skill with tools\nallowed-tools: grep glob bash\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectoryWithRawContent(
|
||
"meta-skill",
|
||
"---\nname: meta-skill\ndescription: A skill with metadata\nmetadata:\n author: test-user\n version: 1.0\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.NotNull(skills[0].Frontmatter.Metadata);
|
||
Assert.Equal("test-user", skills[0].Frontmatter.Metadata!["author"]?.ToString());
|
||
Assert.Equal("1.0", skills[0].Frontmatter.Metadata!["version"]?.ToString());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_MetadataWithQuotedValues_ParsedCorrectlyAsync()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectoryWithRawContent(
|
||
"quoted-meta",
|
||
"---\nname: quoted-meta\ndescription: Metadata with quotes\nmetadata:\n key1: 'single quoted'\n key2: \"double quoted\"\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.NotNull(skills[0].Frontmatter.Metadata);
|
||
Assert.Equal("single quoted", skills[0].Frontmatter.Metadata!["key1"]?.ToString());
|
||
Assert.Equal("double quoted", skills[0].Frontmatter.Metadata!["key2"]?.ToString());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync()
|
||
{
|
||
// Arrange
|
||
string content = string.Join(
|
||
"\n",
|
||
"---",
|
||
"name: full-skill",
|
||
"description: A skill with all fields",
|
||
"license: Apache-2.0",
|
||
"compatibility: Requires Python 3.10+",
|
||
"allowed-tools: grep glob view",
|
||
"metadata:",
|
||
" org: contoso",
|
||
" tier: premium",
|
||
"---",
|
||
"Full body content.");
|
||
_ = this.CreateSkillDirectoryWithRawContent("full-skill", content);
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
var fm = skills[0].Frontmatter;
|
||
Assert.Equal("full-skill", fm.Name);
|
||
Assert.Equal("A skill with all fields", fm.Description);
|
||
Assert.Equal("Apache-2.0", fm.License);
|
||
Assert.Equal("Requires Python 3.10+", fm.Compatibility);
|
||
Assert.Equal("grep glob view", fm.AllowedTools);
|
||
Assert.NotNull(fm.Metadata);
|
||
Assert.Equal("contoso", fm.Metadata!["org"]?.ToString());
|
||
Assert.Equal("premium", fm.Metadata!["tier"]?.ToString());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
var fm = skills[0].Frontmatter;
|
||
Assert.Null(fm.License);
|
||
Assert.Null(fm.Compatibility);
|
||
Assert.Null(fm.AllowedTools);
|
||
Assert.Null(fm.Metadata);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("..")]
|
||
[InlineData("../escape")]
|
||
[InlineData("sub/../escape")]
|
||
[InlineData("/absolute")]
|
||
[InlineData("\\absolute")]
|
||
public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory)
|
||
{
|
||
// Arrange & Act — invalid directories are skipped with a warning rather than throwing
|
||
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] });
|
||
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] });
|
||
|
||
// Assert
|
||
Assert.NotNull(source1);
|
||
Assert.NotNull(source2);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(null)]
|
||
[InlineData("")]
|
||
[InlineData(" ")]
|
||
public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory)
|
||
{
|
||
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
|
||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] }));
|
||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] }));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("scripts")]
|
||
[InlineData("my-scripts")]
|
||
[InlineData("sub/directory")]
|
||
[InlineData(".")]
|
||
[InlineData("./scripts")]
|
||
[InlineData("./scripts/f1")]
|
||
[InlineData("my..scripts")]
|
||
public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory)
|
||
{
|
||
// Arrange & Act & Assert
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] });
|
||
Assert.NotNull(source);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync()
|
||
{
|
||
// Arrange — "references" and "./references" refer to the same directory;
|
||
// after normalization they should be deduplicated so resources appear only once.
|
||
string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — only one copy of the resource despite two equivalent directory entries
|
||
Assert.Single(skills);
|
||
Assert.Single(skills[0].Resources!);
|
||
Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync()
|
||
{
|
||
// Arrange — "references/" should be normalized to "references"
|
||
string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — trailing slash variant deduplicated
|
||
Assert.Single(skills);
|
||
Assert.Single(skills[0].Resources!);
|
||
Assert.Equal("references/data.json", skills[0].Resources![0].Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync()
|
||
{
|
||
// Arrange — ".\\scripts" should be normalized to "scripts"
|
||
string skillDir = Path.Combine(this._testRoot, "backslash-skill");
|
||
string scriptsDir = Path.Combine(skillDir, "scripts");
|
||
Directory.CreateDirectory(scriptsDir);
|
||
File.WriteAllText(Path.Combine(scriptsDir, "run.py"), "print('hello')");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — backslash variant deduplicated
|
||
Assert.Single(skills);
|
||
Assert.Single(skills[0].Scripts!);
|
||
Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("./references")]
|
||
[InlineData("./assets/docs")]
|
||
public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory)
|
||
{
|
||
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
|
||
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
|
||
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
|
||
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
|
||
string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
|
||
Directory.CreateDirectory(targetDir);
|
||
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — the resource is discovered with a name identical to using the directory without "./"
|
||
Assert.Single(skills);
|
||
Assert.Single(skills[0].Resources!);
|
||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync()
|
||
{
|
||
// Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3)
|
||
string skillDir = Path.Combine(this._testRoot, "nested-directory-skill");
|
||
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
|
||
Directory.CreateDirectory(nestedDir);
|
||
File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — resource file inside the deeply nested directory is discovered
|
||
Assert.Single(skills);
|
||
var skill = skills[0];
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name);
|
||
}
|
||
|
||
private string CreateSkillDirectory(string name, string description, string body)
|
||
{
|
||
string skillDir = Path.Combine(this._testRoot, name);
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
|
||
return skillDir;
|
||
}
|
||
|
||
private string CreateSkillDirectoryWithRawContent(string directoryName, string rawContent)
|
||
{
|
||
string skillDir = Path.Combine(this._testRoot, directoryName);
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent);
|
||
return skillDir;
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("txt")]
|
||
[InlineData("")]
|
||
[InlineData(" ")]
|
||
public void Constructor_InvalidScriptExtension_ThrowsArgumentException(string badExtension)
|
||
{
|
||
// Arrange & Act & Assert
|
||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(
|
||
this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { AllowedScriptExtensions = new string[] { badExtension } }));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_SkillBeyondMaxDepth_NotDiscoveredAsync()
|
||
{
|
||
// Arrange — create a skill at depth 3 (exceeds MaxSearchDepth = 2)
|
||
string deepDir = Path.Combine(this._testRoot, "l1", "l2", "l3", "deep-skill");
|
||
Directory.CreateDirectory(deepDir);
|
||
File.WriteAllText(
|
||
Path.Combine(deepDir, "SKILL.md"),
|
||
"---\nname: deep-skill\ndescription: Too deep\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — skill at depth 3 should not be discovered
|
||
Assert.DoesNotContain(skills, s => s.Frontmatter.Name == "deep-skill");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
|
||
{
|
||
// Arrange — script file directly in the skill directory with ScriptDirectories = ["."]
|
||
string skillDir = Path.Combine(this._testRoot, "root-script-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||
new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] });
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — script at the skill root should be discovered
|
||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
|
||
Assert.NotNull(skill);
|
||
Assert.Single(skill.Scripts!);
|
||
Assert.Equal("run.py", skill.Scripts![0].Name);
|
||
}
|
||
|
||
#if NET
|
||
[Fact]
|
||
public async Task GetSkillsAsync_SymlinkedFileInRealDirectory_SkipsSymlinkedFileAsync()
|
||
{
|
||
// Arrange — references/ is a real directory, but one file inside it is a symlink
|
||
// pointing outside the skill directory. The per-file symlink check should skip it.
|
||
string skillDir = Path.Combine(this._testRoot, "symlink-file-skill");
|
||
string refsDir = Path.Combine(skillDir, "references");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "legit.md"), "legit content");
|
||
|
||
string outsideDir = Path.Combine(this._testRoot, "outside-file");
|
||
Directory.CreateDirectory(outsideDir);
|
||
File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
|
||
|
||
string symlinkFile = Path.Combine(refsDir, "leak.md");
|
||
try
|
||
{
|
||
File.CreateSymbolicLink(symlinkFile, Path.Combine(outsideDir, "secret.md"));
|
||
}
|
||
catch (IOException)
|
||
{
|
||
// Symlink creation requires elevation on some platforms; skip gracefully.
|
||
return;
|
||
}
|
||
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: symlink-file-skill\ndescription: Symlinked file\n---\nBody.");
|
||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||
|
||
// Act
|
||
var skills = await source.GetSkillsAsync();
|
||
|
||
// Assert — only legit.md should be discovered; the symlinked leak.md is skipped
|
||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-file-skill");
|
||
Assert.NotNull(skill);
|
||
Assert.Single(skill.Resources!);
|
||
Assert.Equal("references/legit.md", skill.Resources![0].Name);
|
||
}
|
||
#endif
|
||
}
|