diff --git a/.editorconfig b/.editorconfig index 69fc164..9015a8b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,6 @@ +[*] +indent_style = space + [*.cs] # 使用显式类型 csharp_style_var_for_built_in_types = false:warning @@ -6,3 +9,6 @@ csharp_style_var_elsewhere = false:warning # 多余限定符 resharper_redundant_name_qualifier_highlighting = hint + +[*.{csproj,slnx}] +indent_size = 2 diff --git a/.github/workflows/xUnit.yaml b/.github/workflows/xUnit.yaml new file mode 100644 index 0000000..89865f5 --- /dev/null +++ b/.github/workflows/xUnit.yaml @@ -0,0 +1,35 @@ +name: xUnit 单元测试 + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: {} + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + + steps: + - name: 检出代码 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: 设置 .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.x + + - name: 运行测试 + run: dotnet test "src/" diff --git a/global.json b/global.json new file mode 100644 index 0000000..8f73781 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/src/PinAction.slnx b/src/PinAction.slnx index f4dde52..ae774cc 100644 --- a/src/PinAction.slnx +++ b/src/PinAction.slnx @@ -1,3 +1,4 @@ + diff --git a/src/PinAction.tests/PinAction.tests.csproj b/src/PinAction.tests/PinAction.tests.csproj new file mode 100644 index 0000000..4fdf1d4 --- /dev/null +++ b/src/PinAction.tests/PinAction.tests.csproj @@ -0,0 +1,27 @@ + + + + enable + enable + Exe + net10.0 + + + + + + + + + + + + + + + + + + + + diff --git a/src/PinAction.tests/UnitTests.cs b/src/PinAction.tests/UnitTests.cs new file mode 100644 index 0000000..055f7be --- /dev/null +++ b/src/PinAction.tests/UnitTests.cs @@ -0,0 +1,277 @@ +using Moq; +using Octokit; + +namespace PinAction.Tests +{ + /// + /// 定义测试集合,禁用并行化以避免对共享资源的冲突。 + /// + [CollectionDefinition(nameof(ProgramTestsCollection), DisableParallelization = true)] + public sealed class ProgramTestsCollection; + + /// + /// 单元测试类。 + /// + [Collection(nameof(ProgramTestsCollection))] + public sealed class UnitTests : IDisposable + { + /// + /// 用于测试的固定提交 SHA 值。 + /// + private const string CommitSha = "0123456789abcdef0123456789abcdef01234567"; + + /// + /// 用于测试的临时目录路径。 + /// + private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory().FullName; + + /// + /// 原始的 GitHub 客户端实例,用于在测试完成后恢复。 + /// + private readonly IGitHubClient _originalGitHubClient = Program.GitHubClient; + + /// + /// 用于测试的 GitHub 客户端模拟实例。 + /// + private readonly Mock _githubClient = new(MockBehavior.Strict); + + /// + /// 用于测试的 Git 数据库客户端模拟实例。 + /// + private readonly Mock _gitDatabase = new(MockBehavior.Strict); + + /// + /// 用于测试的 Git 标签客户端模拟实例。 + /// + private readonly Mock _tags = new(MockBehavior.Strict); + + /// + /// 用于测试的引用客户端模拟实例。 + /// + private readonly Mock _references = new(MockBehavior.Strict); + + /// + /// 用于测试的仓库客户端模拟实例。 + /// + private readonly Mock _repositories = new(MockBehavior.Strict); + + /// + /// 初始化单元测试类的构造函数。 + /// + public UnitTests() + { + // 设置 GitHub 客户端的模拟行为 + _gitDatabase.Setup(client => client.Reference).Returns(_references.Object); + _gitDatabase.Setup(client => client.Tag).Returns(_tags.Object); + _githubClient.Setup(client => client.Git).Returns(_gitDatabase.Object); + _githubClient.Setup(client => client.Repository).Returns(_repositories.Object); + + Program.PinedActions.Clear(); + Program.GitHubClient = _githubClient.Object; + } + + [Fact(DisplayName = "CompareVersionStrings 方法应按自然顺序比较版本字符串")] + public void CompareVersionStrings_OrdersNumericSegmentsNaturally() + { + Assert.True(Program.CompareVersionStrings("v1.9.0", "v1.10.0") < 0); + Assert.True(Program.CompareVersionStrings("v2.0", "v1.99") > 0); + Assert.Equal(0, Program.CompareVersionStrings("release-1.2", "v1.2.0")); + } + + [Fact(DisplayName = "ResolveRefinedTag 方法应返回与提交匹配的最高版本标签")] + public void ResolveRefinedTag_ReturnsHighestVersionForMatchingCommit() + { + _repositories + .Setup(client => client.GetAllTags("owner", "repo")) + .Returns(Task.FromResult>( + [ + CreateRepositoryTag("v1.9.0", CommitSha), + CreateRepositoryTag("v1.10.0", CommitSha), + CreateRepositoryTag("v99.0.0", "ffffffffffffffffffffffffffffffffffffffff") + ])); + + string result = Program.ResolveRefinedTag("owner/repo", "main", CommitSha); + + Assert.Equal("v1.10.0", result); + _repositories.Verify(client => client.GetAllTags("owner", "repo"), Times.Once); + } + + [Fact(DisplayName = "ResolveRefinedTag 方法应在提交没有匹配标签时返回原始标签")] + public void ResolveRefinedTag_ReturnsOriginalTagWhenCommitHasNoMatchingTag() + { + _repositories + .Setup(client => client.GetAllTags("owner", "repo")) + .Returns(Task.FromResult>( + [ + CreateRepositoryTag("v1.0.0", "ffffffffffffffffffffffffffffffffffffffff") + ])); + + string result = Program.ResolveRefinedTag("owner/repo", "main", CommitSha); + + Assert.Equal("main", result); + } + + [Fact(DisplayName = "PinActionHash 方法应正确固定引用并保留内联注释")] + public void PinActionHash_PinsReferencesAndPreservesInlineComments() + { + string path = Path.Combine(_temporaryDirectory, "workflow.yml"); + File.WriteAllLines(path, + [ + "name: test", + " uses: owner/repo@v1 # keep this comment", + " uses: owner/repo@v1", + " run: echo uses: not-an-action" + ]); + + _references + .Setup(client => client.Get("owner", "repo", "tags/v1")) + .Returns(Task.FromResult(CreateReference(CommitSha))); + _repositories + .Setup(client => client.GetAllTags("owner", "repo")) + .Returns(Task.FromResult>( + [ + CreateRepositoryTag("v1.2.0", CommitSha) + ])); + + bool succeeded = Program.PinActionHash(path); + + Assert.True(succeeded); + string[] expectedLines = + [ + "name: test", + $" uses: owner/repo@{CommitSha} # v1.2.0 keep this comment", // # 前面的空格数应保持原样 + $" uses: owner/repo@{CommitSha} # v1.2.0", // 默认两个空格 + " run: echo uses: not-an-action" + ]; + Assert.Equal(expectedLines, File.ReadAllLines(path)); + _references.Verify(client => client.Get("owner", "repo", "tags/v1"), Times.Once); + _tags.Verify(client => client.Get( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + _repositories.Verify(client => client.GetAllTags("owner", "repo"), Times.Once); + } + + [Fact(DisplayName = "PinActionHash 方法应将附注标签对象解析为其目标提交哈希")] + public void PinActionHash_ResolvesAnnotatedTagToCommitHash() + { + const string tagObjectSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + string path = Path.Combine(_temporaryDirectory, "workflow.yml"); + File.WriteAllText(path, " uses: owner/repo@v1"); + + _references + .Setup(client => client.Get("owner", "repo", "tags/v1")) + .Returns(Task.FromResult(CreateReference(tagObjectSha, TaggedType.Tag))); + _tags + .Setup(client => client.Get("owner", "repo", tagObjectSha)) + .Returns(Task.FromResult(CreateTagObject(CommitSha, TaggedType.Commit))); + _repositories + .Setup(client => client.GetAllTags("owner", "repo")) + .Returns(Task.FromResult>([])); + + bool succeeded = Program.PinActionHash(path); + + Assert.True(succeeded); + Assert.Equal($" uses: owner/repo@{CommitSha} # v1", File.ReadAllText(path).TrimEnd('\r', '\n')); + _tags.Verify(client => client.Get("owner", "repo", tagObjectSha), Times.Once); + } + + [Fact(DisplayName = "PinActionHash 方法应跳过最终目标不是提交的附注标签")] + public void PinActionHash_SkipsAnnotatedTagWhenResolvedTargetIsNotCommit() + { + const string tagObjectSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const string treeSha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + string path = Path.Combine(_temporaryDirectory, "workflow.yml"); + const string originalLine = " uses: owner/repo@v1"; + File.WriteAllText(path, originalLine); + + _references + .Setup(client => client.Get("owner", "repo", "tags/v1")) + .Returns(Task.FromResult(CreateReference(tagObjectSha, TaggedType.Tag))); + _tags + .Setup(client => client.Get("owner", "repo", tagObjectSha)) + .Returns(Task.FromResult(CreateTagObject(treeSha, TaggedType.Tree))); + + bool result = Program.PinActionHash(path); + + Assert.Equal(originalLine, File.ReadAllText(path).TrimEnd('\r', '\n')); + Assert.False(Program.PinedActions.ContainsKey("owner/repo@v1")); + Assert.False(result); + _tags.Verify(client => client.Get("owner", "repo", tagObjectSha), Times.Once); + _repositories.Verify(client => client.GetAllTags( + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact(DisplayName = "PinActionHash 方法应跳过已固定的和无效的仓库引用")] + public void PinActionHash_SkipsAlreadyPinnedAndInvalidRepositoryReferences() + { + string path = Path.Combine(_temporaryDirectory, "workflow.yml"); + string[] originalLines = + [ + $" uses: owner/repo@{CommitSha}", + " uses: local-action@v1", + " uses: owner/repo/path@v1" + ]; + File.WriteAllLines(path, originalLines); + + bool succeeded = Program.PinActionHash(path); + + Assert.True(succeeded); + Assert.Equal(originalLines, File.ReadAllLines(path)); + _references.Verify(client => client.Get( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + _repositories.Verify(client => client.GetAllTags( + It.IsAny(), + It.IsAny()), Times.Never); + } + + /// + /// 还原单元测试使用的资源。 + /// + public void Dispose() + { + Program.PinedActions.Clear(); + Program.GitHubClient = _originalGitHubClient; + Directory.Delete(_temporaryDirectory, recursive: true); + } + + /// + /// 创建一个引用实例。 + /// + /// 提交的 SHA。 + /// 目标对象的类型。 + /// 引用实例。 + private static Reference CreateReference(string sha, TaggedType type = TaggedType.Commit) + { + TagObject target = new("", "", "", "", sha, null, null, type); + return new Reference("refs/tags/v1", "", "", target); + } + + /// + /// 创建一个附注标签对象实例。 + /// + /// 目标对象的 SHA。 + /// 目标对象的类型。 + /// 附注标签对象实例。 + private static GitTag CreateTagObject(string sha, TaggedType type) + { + TagObject target = new("", "", "", "", sha, null, null, type); + return new GitTag("", "", "", "", "", null, null, "", "", null, target, null); + } + + /// + /// 创建一个仓库标签实例。 + /// + /// 标签的名称。 + /// 提交的 SHA。 + /// 仓库标签实例。 + private static RepositoryTag CreateRepositoryTag(string name, string sha) + { + GitReference commit = new("", "", "", "", sha, null!, null!); + return new RepositoryTag(name, "", commit, "", ""); + } + } +} diff --git a/src/PinAction.tests/xunit.runner.json b/src/PinAction.tests/xunit.runner.json new file mode 100644 index 0000000..86c7ea0 --- /dev/null +++ b/src/PinAction.tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} diff --git a/src/PinAction/PinAction.csproj b/src/PinAction/PinAction.csproj index 63b343b..9f35c49 100644 --- a/src/PinAction/PinAction.csproj +++ b/src/PinAction/PinAction.csproj @@ -23,6 +23,10 @@ + + + + / diff --git a/src/PinAction/Program.cs b/src/PinAction/Program.cs index d45f42b..2662bef 100644 --- a/src/PinAction/Program.cs +++ b/src/PinAction/Program.cs @@ -102,7 +102,7 @@ args is ["help" /// /// 要处理的 YAML / YML 工作流文件路径。 /// 若文件处理成功返回 ;失败返回 。 - private static bool PinActionHash(string path) + internal static bool PinActionHash(string path) { // 读取文件内容,并按行分隔 string[] lines = File.ReadAllLines(path); @@ -230,7 +230,7 @@ private static bool PinActionHash(string path) /// 原始引用的版本或分支名称。 /// 对应的提交 SHA,通常为 40 位十六进制值。 /// 若能在目标仓库中找到与提交 SHA 对应的标签,则返回最合适的标签名;否则返回原始 。 - private static string ResolveRefinedTag(string repo, string tag, string hash) + internal static string ResolveRefinedTag(string repo, string tag, string hash) { if (string.IsNullOrWhiteSpace(tag) || string.IsNullOrWhiteSpace(hash)) { @@ -270,7 +270,7 @@ private static string ResolveRefinedTag(string repo, string tag, string hash) /// 左侧版本字符串。 /// 右侧版本字符串。 /// 若 小于 返回负数;相等返回 0;大于返回正数。 - private static int CompareVersionStrings(string left, string right) + internal static int CompareVersionStrings(string left, string right) { int[] leftParts = [.. NumberRegex().Matches(left).Select(m => int.Parse(m.Value))]; int[] rightParts = [.. NumberRegex().Matches(right).Select(m => int.Parse(m.Value))]; @@ -293,7 +293,7 @@ private static int CompareVersionStrings(string left, string right) /// /// GitHub API 客户端。 /// - private static readonly GitHubClient GitHubClient = new(new ProductHeaderValue("PinAction")) + internal static IGitHubClient GitHubClient { get; set; } = new GitHubClient(new ProductHeaderValue("PinAction")) { // 如果你想让请求使用 GitHub Token,可以将 Token 临时填在这里 // 记得用完后 撤销/删除/轮换 Token。 @@ -304,7 +304,7 @@ private static int CompareVersionStrings(string left, string right) /// 缓存已固定哈希值的 Action,避免同一 repo@tag 重复调用 GitHub API。 /// 键为 repo@tag,值为对应的提交 SHA。 /// - private static readonly ConcurrentDictionary PinedActions = new(); + internal static readonly ConcurrentDictionary PinedActions = new(); /// /// 匹配工作流中的 uses: 引用,提取仓库名和对应版本或分支名。