mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
7ba636d642
* support agent skills * make the new agent skill provider experimental * Fix file encoding: add UTF-8 BOM to .cs files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix final newline and simplify new expressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix broken links in Agent Skills sample README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add null check for skillPaths parameter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Normalize references * normilize skill path * address comments regarding symlink check * address comments * fix failing test + regex improvements * small optimizations and improvments * address pr review comments * Update dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> * address pr review comments * address pr review comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
562 lines
21 KiB
C#
562 lines
21 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
||
|
||
using System;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Threading.Tasks;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
|
||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||
|
||
/// <summary>
|
||
/// Unit tests for the <see cref="FileAgentSkillLoader"/> class.
|
||
/// </summary>
|
||
public sealed class FileAgentSkillLoaderTests : IDisposable
|
||
{
|
||
private static readonly string[] s_traversalResource = new[] { "../secret.txt" };
|
||
|
||
private readonly string _testRoot;
|
||
private readonly FileAgentSkillLoader _loader;
|
||
|
||
public FileAgentSkillLoaderTests()
|
||
{
|
||
this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N"));
|
||
Directory.CreateDirectory(this._testRoot);
|
||
this._loader = new FileAgentSkillLoader(NullLogger.Instance);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (Directory.Exists(this._testRoot))
|
||
{
|
||
Directory.Delete(this._testRoot, recursive: true);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.True(skills.ContainsKey("my-skill"));
|
||
Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description);
|
||
Assert.Equal("Use this skill to do things.", skills["my-skill"].Body);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly()
|
||
{
|
||
// 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.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name);
|
||
Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "bad-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill()
|
||
{
|
||
// 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.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "no-desc");
|
||
Directory.CreateDirectory(skillDir);
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: no-desc\n---\nBody.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("BadName")]
|
||
[InlineData("-leading-hyphen")]
|
||
[InlineData("trailing-hyphen-")]
|
||
[InlineData("has spaces")]
|
||
public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName)
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "invalid-name-test");
|
||
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.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly()
|
||
{
|
||
// Arrange
|
||
string dir1 = Path.Combine(this._testRoot, "skill-a");
|
||
string dir2 = Path.Combine(this._testRoot, "skill-b");
|
||
Directory.CreateDirectory(dir1);
|
||
Directory.CreateDirectory(dir2);
|
||
File.WriteAllText(
|
||
Path.Combine(dir1, "SKILL.md"),
|
||
"---\nname: dupe\ndescription: First\n---\nFirst body.");
|
||
File.WriteAllText(
|
||
Path.Combine(dir2, "SKILL.md"),
|
||
"---\nname: dupe\ndescription: Second\n---\nSecond body.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// 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["dupe"].Frontmatter.Description;
|
||
Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}");
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_WithValidResourceLinks_ExtractsResourceNames()
|
||
{
|
||
// Arrange
|
||
string skillDir = Path.Combine(this._testRoot, "resource-skill");
|
||
string refsDir = Path.Combine(skillDir, "refs");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: resource-skill\ndescription: Has resources\n---\nSee [FAQ](refs/FAQ.md) for details.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
var skill = skills["resource-skill"];
|
||
Assert.Single(skill.ResourceNames);
|
||
Assert.Equal("refs/FAQ.md", skill.ResourceNames[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_PathTraversal_ExcludesSkill()
|
||
{
|
||
// Arrange — resource links outside the skill directory
|
||
string skillDir = Path.Combine(this._testRoot, "traversal-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
|
||
// Create a file outside the skill dir that the traversal would resolve to
|
||
File.WriteAllText(Path.Combine(this._testRoot, "secret.txt"), "secret");
|
||
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: traversal-skill\ndescription: Traversal attempt\n---\nSee [doc](../secret.txt).");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary()
|
||
{
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty<string>());
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary()
|
||
{
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") });
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit()
|
||
{
|
||
// 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.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.True(skills.ContainsKey("nested-skill"));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
|
||
{
|
||
// Arrange
|
||
_ = this.CreateSkillDirectoryWithResource("read-skill", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content here.");
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
var skill = skills["read-skill"];
|
||
|
||
// Act
|
||
string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md");
|
||
|
||
// Assert
|
||
Assert.Equal("Document content here.", content);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync()
|
||
{
|
||
// Arrange
|
||
string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources.");
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
var skill = skills["simple-skill"];
|
||
|
||
// Act & Assert
|
||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||
() => this._loader.ReadSkillResourceAsync(skill, "unknown.md"));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync()
|
||
{
|
||
// Arrange — skill with a legitimate resource, then try to read a traversal path at read time
|
||
_ = this.CreateSkillDirectoryWithResource("traverse-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "legit");
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
var skill = skills["traverse-read"];
|
||
|
||
// Manually construct a skill with the traversal resource in its list to bypass discovery validation
|
||
var tampered = new FileAgentSkill(
|
||
skill.Frontmatter,
|
||
skill.Body,
|
||
skill.SourcePath,
|
||
s_traversalResource);
|
||
|
||
// Act & Assert
|
||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||
() => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt"));
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill()
|
||
{
|
||
// 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.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill()
|
||
{
|
||
// 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.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Empty(skills);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_DuplicateResourceLinks_DeduplicatesResources()
|
||
{
|
||
// Arrange — body references the same resource twice
|
||
string skillDir = Path.Combine(this._testRoot, "dedup-skill");
|
||
string refsDir = Path.Combine(skillDir, "refs");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: dedup-skill\ndescription: Dedup test\n---\nSee [doc](refs/doc.md) and [again](refs/doc.md).");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.Single(skills["dedup-skill"].ResourceNames);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_DotSlashPrefix_NormalizesToBarePath()
|
||
{
|
||
// Arrange — body references a resource with ./ prefix
|
||
string skillDir = Path.Combine(this._testRoot, "dotslash-skill");
|
||
string refsDir = Path.Combine(skillDir, "refs");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: dotslash-skill\ndescription: Dot-slash test\n---\nSee [doc](./refs/doc.md).");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
var skill = skills["dotslash-skill"];
|
||
Assert.Single(skill.ResourceNames);
|
||
Assert.Equal("refs/doc.md", skill.ResourceNames[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_DotSlashAndBarePath_DeduplicatesResources()
|
||
{
|
||
// Arrange — body references the same resource with and without ./ prefix
|
||
string skillDir = Path.Combine(this._testRoot, "mixed-prefix-skill");
|
||
string refsDir = Path.Combine(skillDir, "refs");
|
||
Directory.CreateDirectory(refsDir);
|
||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content");
|
||
File.WriteAllText(
|
||
Path.Combine(skillDir, "SKILL.md"),
|
||
"---\nname: mixed-prefix-skill\ndescription: Mixed prefix test\n---\nSee [a](./refs/doc.md) and [b](refs/doc.md).");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
var skill = skills["mixed-prefix-skill"];
|
||
Assert.Single(skill.ResourceNames);
|
||
Assert.Equal("refs/doc.md", skill.ResourceNames[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync()
|
||
{
|
||
// Arrange — skill loaded with bare path, caller uses ./ prefix
|
||
_ = this.CreateSkillDirectoryWithResource("dotslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content.");
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
var skill = skills["dotslash-read"];
|
||
|
||
// Act — caller passes ./refs/doc.md which should match refs/doc.md
|
||
string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md");
|
||
|
||
// Assert
|
||
Assert.Equal("Document content.", content);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync()
|
||
{
|
||
// Arrange — skill loaded with forward-slash path, caller uses backslashes
|
||
_ = this.CreateSkillDirectoryWithResource("backslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Backslash content.");
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
var skill = skills["backslash-read"];
|
||
|
||
// Act — caller passes refs\doc.md which should match refs/doc.md
|
||
string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md");
|
||
|
||
// Assert
|
||
Assert.Equal("Backslash content.", content);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync()
|
||
{
|
||
// Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes
|
||
_ = this.CreateSkillDirectoryWithResource("mixed-sep-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Mixed separator content.");
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
var skill = skills["mixed-sep-read"];
|
||
|
||
// Act — caller passes .\refs\doc.md which should match refs/doc.md
|
||
string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md");
|
||
|
||
// Assert
|
||
Assert.Equal("Mixed separator content.", content);
|
||
}
|
||
|
||
#if NET
|
||
private static readonly string[] s_symlinkResource = ["refs/data.md"];
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_SymlinkInPath_ExcludesSkill()
|
||
{
|
||
// Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory
|
||
string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill");
|
||
Directory.CreateDirectory(skillDir);
|
||
|
||
string outsideDir = Path.Combine(this._testRoot, "outside");
|
||
Directory.CreateDirectory(outsideDir);
|
||
File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content");
|
||
|
||
string refsLink = Path.Combine(skillDir, "refs");
|
||
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---\nSee [doc](refs/secret.md).");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert — skill should be excluded because refs/ is a symlink (reparse point)
|
||
Assert.False(skills.ContainsKey("symlink-escape-skill"));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync()
|
||
{
|
||
// Arrange — build a skill with a symlinked subdirectory
|
||
string skillDir = Path.Combine(this._testRoot, "symlink-read-skill");
|
||
string refsDir = Path.Combine(skillDir, "refs");
|
||
Directory.CreateDirectory(skillDir);
|
||
|
||
string outsideDir = Path.Combine(this._testRoot, "outside-read");
|
||
Directory.CreateDirectory(outsideDir);
|
||
File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data");
|
||
|
||
try
|
||
{
|
||
Directory.CreateSymbolicLink(refsDir, outsideDir);
|
||
}
|
||
catch (IOException)
|
||
{
|
||
// Symlink creation requires elevation on some platforms; skip gracefully.
|
||
return;
|
||
}
|
||
|
||
// Manually construct a skill that bypasses discovery validation
|
||
var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill");
|
||
var skill = new FileAgentSkill(
|
||
frontmatter: frontmatter,
|
||
body: "See [doc](refs/data.md).",
|
||
sourcePath: skillDir,
|
||
resourceNames: s_symlinkResource);
|
||
|
||
// Act & Assert
|
||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||
() => this._loader.ReadSkillResourceAsync(skill, "refs/data.md"));
|
||
}
|
||
#endif
|
||
|
||
[Fact]
|
||
public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully()
|
||
{
|
||
// 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.");
|
||
|
||
// Act
|
||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||
|
||
// Assert
|
||
Assert.Single(skills);
|
||
Assert.True(skills.ContainsKey("bom-skill"));
|
||
Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description);
|
||
Assert.Equal("Body content.", skills["bom-skill"].Body);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
private string CreateSkillDirectoryWithResource(string name, string description, string body, string resourceRelativePath, string resourceContent)
|
||
{
|
||
string skillDir = this.CreateSkillDirectory(name, description, body);
|
||
string resourcePath = Path.Combine(skillDir, resourceRelativePath);
|
||
Directory.CreateDirectory(Path.GetDirectoryName(resourcePath)!);
|
||
File.WriteAllText(resourcePath, resourceContent);
|
||
return skillDir;
|
||
}
|
||
}
|