File: WorkflowScripts\ExtensionReleaseWorkflowTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Text;
using Aspire.TestUtilities;
using Xunit;
 
namespace Infrastructure.Tests;
 
public sealed class ExtensionReleaseWorkflowTests(ITestOutputHelper testOutput)
{
    private static readonly string s_releaseNotesGeneratorPath = Path.Combine(
        RepoRoot.Path,
        ".github",
        "workflows",
        "extension-release",
        "generate_deterministic_release_notes.py");
    private static readonly string s_applyTriggerLabelScriptPath = Path.Combine(
        RepoRoot.Path,
        ".github",
        "workflows",
        "extension-release",
        "apply_extension_release_trigger_label.sh");
    private static readonly string s_prBodyValidatorPath = Path.Combine(
        RepoRoot.Path,
        ".github",
        "workflows",
        "extension-release",
        "validate_github_pr_body.py");
 
    [Fact]
    [RequiresTools(["bash"])]
    public async Task ApplyingTriggerLabelFailsWhenExistingLabelCannotBeRemoved()
    {
        var tempDirectory = Directory.CreateTempSubdirectory("extension-release-label-failure");
        try
        {
            var fakeGh = await CreateFakeGhAsync(tempDirectory.FullName);
            var result = await RunBashScriptAsync(
                s_applyTriggerLabelScriptPath,
                ["123"],
                new Dictionary<string, string?>
                {
                    ["GH_CALL_LOG"] = fakeGh.CallLogPath,
                    ["GH_HAS_LABEL"] = "true",
                    ["GH_REMOVE_LABEL_EXIT_CODE"] = "1",
                    ["PATH"] = fakeGh.PathEnvironment,
                });
 
            Assert.NotEqual(0, result.ExitCode);
            Assert.Contains("Failed to remove existing 'vscode-extension-release' label", result.Output, StringComparison.Ordinal);
            Assert.False(
                (await File.ReadAllTextAsync(fakeGh.CallLogPath)).Contains("--add-label vscode-extension-release", StringComparison.Ordinal),
                "The helper must not re-add the label after a failed removal.");
        }
        finally
        {
            Directory.Delete(tempDirectory.FullName, recursive: true);
        }
    }
 
    [Fact]
    [RequiresTools(["bash"])]
    public async Task ApplyingTriggerLabelAddsLabelWithoutRemovingWhenMissing()
    {
        var tempDirectory = Directory.CreateTempSubdirectory("extension-release-label-add");
        try
        {
            var fakeGh = await CreateFakeGhAsync(tempDirectory.FullName);
            var result = await RunBashScriptAsync(
                s_applyTriggerLabelScriptPath,
                ["123"],
                new Dictionary<string, string?>
                {
                    ["GH_CALL_LOG"] = fakeGh.CallLogPath,
                    ["GH_HAS_LABEL"] = "false",
                    ["PATH"] = fakeGh.PathEnvironment,
                });
 
            Assert.Equal(0, result.ExitCode);
 
            var callLog = await File.ReadAllTextAsync(fakeGh.CallLogPath);
            Assert.Contains("pr view 123 --json labels --jq .labels[].name", callLog, StringComparison.Ordinal);
            Assert.DoesNotContain("--remove-label vscode-extension-release", callLog, StringComparison.Ordinal);
            Assert.Contains("pr edit 123 --add-label vscode-extension-release", callLog, StringComparison.Ordinal);
        }
        finally
        {
            Directory.Delete(tempDirectory.FullName, recursive: true);
        }
    }
 
    [Fact]
    [RequiresTools(["bash"])]
    public async Task ApplyingTriggerLabelReAddsExistingLabelAfterSuccessfulRemoval()
    {
        var tempDirectory = Directory.CreateTempSubdirectory("extension-release-label-readd");
        try
        {
            var fakeGh = await CreateFakeGhAsync(tempDirectory.FullName);
            var result = await RunBashScriptAsync(
                s_applyTriggerLabelScriptPath,
                ["123"],
                new Dictionary<string, string?>
                {
                    ["GH_CALL_LOG"] = fakeGh.CallLogPath,
                    ["GH_HAS_LABEL"] = "true",
                    ["PATH"] = fakeGh.PathEnvironment,
                });
 
            Assert.Equal(0, result.ExitCode);
 
            var callLog = (await File.ReadAllTextAsync(fakeGh.CallLogPath)).ReplaceLineEndings("\n");
            var removeIndex = callLog.IndexOf("pr edit 123 --remove-label vscode-extension-release", StringComparison.Ordinal);
            var addIndex = callLog.IndexOf("pr edit 123 --add-label vscode-extension-release", StringComparison.Ordinal);
 
            Assert.True(removeIndex >= 0, "Expected the helper to remove the existing trigger label before re-adding it.");
            Assert.True(addIndex > removeIndex, "Expected the helper to re-add the trigger label after removing it.");
        }
        finally
        {
            Directory.Delete(tempDirectory.FullName, recursive: true);
        }
    }
 
    [Fact]
    [RequiresTools(["python"])]
    [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
    public Task DeterministicFallbackIncludesEveryAcceptedCommitOnWindows()
        => DeterministicFallbackIncludesEveryAcceptedCommit("python");
 
    [Fact]
    [RequiresTools(["python3"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
    public Task DeterministicFallbackIncludesEveryAcceptedCommitOnUnix()
        => DeterministicFallbackIncludesEveryAcceptedCommit("python3");
 
    [Fact]
    [RequiresTools(["python"])]
    [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
    public Task DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytesOnWindows()
        => DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytes("python");
 
    [Fact]
    [RequiresTools(["python3"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
    public Task DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytesOnUnix()
        => DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytes("python3");
 
    [Fact]
    [RequiresTools(["python"])]
    [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
    public Task DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommitsOnWindows()
        => DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommits("python");
 
    [Fact]
    [RequiresTools(["python3"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
    public Task DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommitsOnUnix()
        => DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommits("python3");
 
    [Fact]
    [RequiresTools(["python"])]
    [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
    public Task DeterministicFallbackStripsPrSuffixFromCrLfInputOnWindows()
        => DeterministicFallbackStripsPrSuffixFromCrLfInput("python");
 
    [Fact]
    [RequiresTools(["python3"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
    public Task DeterministicFallbackStripsPrSuffixFromCrLfInputOnUnix()
        => DeterministicFallbackStripsPrSuffixFromCrLfInput("python3");
 
    [Fact]
    [RequiresTools(["python"])]
    [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
    public Task GitHubPullRequestBodyValidatorAcceptsBodiesAtLimitOnWindows()
        => GitHubPullRequestBodyValidatorAcceptsBodiesAtLimit("python");
 
    [Fact]
    [RequiresTools(["python3"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
    public Task GitHubPullRequestBodyValidatorAcceptsBodiesAtLimitOnUnix()
        => GitHubPullRequestBodyValidatorAcceptsBodiesAtLimit("python3");
 
    [Fact]
    [RequiresTools(["python"])]
    [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
    public Task GitHubPullRequestBodyValidatorRejectsBodiesOverLimitOnWindows()
        => GitHubPullRequestBodyValidatorRejectsBodiesOverLimit("python");
 
    [Fact]
    [RequiresTools(["python3"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
    public Task GitHubPullRequestBodyValidatorRejectsBodiesOverLimitOnUnix()
        => GitHubPullRequestBodyValidatorRejectsBodiesOverLimit("python3");
 
    private async Task DeterministicFallbackIncludesEveryAcceptedCommit(string pythonExecutable)
    {
        var output = await GenerateDeterministicReleaseNotesAsync(
            pythonExecutable,
            [
                "1111111\tfeat: Add Aspire dashboard tree view (#100)",
                "2222222\tfix(settings): Preserve <b>launch</b> profile labels (#101)",
                "3333333\tdocs: Explain terminal attach behavior (#102)",
                "4444444\tchore(extension): Surface package warnings\a in output (#103)",
                "5555555\trefactor: Improve project detection after restore (#104)",
                "6666666\tperf: Speed up solution scanning (#105)",
                "7777777\tfeat(tree): Show Azure resources in explorer (#106)",
                "8888888\tfix: Respect multi-root workspaces (#107)",
                "9999999\tfeat: Add walkthrough links (#108)",
                "aaaaaaa\tfix(debug): Keep env vars when reloading (#109)",
                "bbbbbbb\tfeat(commands): Support remove service action (#110)",
                "ccccccc\tchore: Handle workspace rename notifications (#111)",
                "ddddddd\tRelease 1.10.1",
                "eeeeeee\tBump package-lock.json",
                "fffffff\tUpdate yarn.lock"
            ]);
 
        var bulletLines = output.ReplaceLineEndings("\n")
            .Split('\n', StringSplitOptions.RemoveEmptyEntries)
            .Where(line => line.StartsWith("- ", StringComparison.Ordinal))
            .ToArray();
 
        Assert.Equal(
            [
                "- Add Aspire dashboard tree view",
                "- Preserve launch profile labels",
                "- Explain terminal attach behavior",
                "- Surface package warnings in output",
                "- Improve project detection after restore",
                "- Speed up solution scanning",
                "- Show Azure resources in explorer",
                "- Respect multi-root workspaces",
                "- Add walkthrough links",
                "- Keep env vars when reloading",
                "- Support remove service action",
                "- Handle workspace rename notifications"
            ],
            bulletLines);
    }
 
    private async Task DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytes(string pythonExecutable)
    {
        var commitLines = Enumerable.Range(1, 12)
            .Select(index =>
            {
                var message = $"candidate note {index:D2} " + new string((char)('a' + (index % 26)), 720);
                return $"{index:x7}\tfeat: {message}";
            })
            .ToArray();
 
        var output = await GenerateDeterministicReleaseNotesAsync(pythonExecutable, commitLines);
        var lastMessage = $"candidate note 12 {new string('m', 720)}";
 
        Assert.True(
            Encoding.UTF8.GetByteCount(output) > 8000,
            $"Expected rendered fallback to exceed 8000 bytes, but it was {Encoding.UTF8.GetByteCount(output)} bytes.");
        Assert.Contains($"- {lastMessage}", output, StringComparison.Ordinal);
    }
 
    private async Task DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommits(string pythonExecutable)
    {
        var output = await GenerateDeterministicReleaseNotesAsync(
            pythonExecutable,
            [
                "1111111\tfeat: Alpha\rBeta (#100)",
                "2222222\tfix: Gamma\vDelta (#101)"
            ]);
 
        var bulletLines = output.ReplaceLineEndings("\n")
            .Split('\n', StringSplitOptions.RemoveEmptyEntries)
            .Where(line => line.StartsWith("- ", StringComparison.Ordinal))
            .ToArray();
 
        Assert.Equal(
            [
                "- AlphaBeta",
                "- GammaDelta"
            ],
            bulletLines);
    }
 
    private async Task DeterministicFallbackStripsPrSuffixFromCrLfInput(string pythonExecutable)
    {
        var output = await GenerateDeterministicReleaseNotesAsync(
            pythonExecutable,
            ["1111111\tfeat: Alpha (#100)"],
            lineEnding: "\r\n");
 
        var bulletLines = output.ReplaceLineEndings("\n")
            .Split('\n', StringSplitOptions.RemoveEmptyEntries)
            .Where(line => line.StartsWith("- ", StringComparison.Ordinal))
            .ToArray();
 
        Assert.Equal(["- Alpha"], bulletLines);
    }
 
    private async Task GitHubPullRequestBodyValidatorAcceptsBodiesAtLimit(string pythonExecutable)
    {
        var tempDirectory = Directory.CreateTempSubdirectory("extension-release-pr-body-limit-pass");
        try
        {
            var bodyPath = Path.Combine(tempDirectory.FullName, "pr_body.md");
            await File.WriteAllTextAsync(bodyPath, new string('a', 65_536));
 
            var result = await RunPythonScriptAsync(pythonExecutable, s_prBodyValidatorPath, [bodyPath]);
 
            Assert.Equal(0, result.ExitCode);
            Assert.Contains("GitHub pull request body length", result.Output, StringComparison.Ordinal);
        }
        finally
        {
            Directory.Delete(tempDirectory.FullName, recursive: true);
        }
    }
 
    private async Task GitHubPullRequestBodyValidatorRejectsBodiesOverLimit(string pythonExecutable)
    {
        var tempDirectory = Directory.CreateTempSubdirectory("extension-release-pr-body-limit-fail");
        try
        {
            var bodyPath = Path.Combine(tempDirectory.FullName, "pr_body.md");
            await File.WriteAllTextAsync(bodyPath, new string('a', 65_537));
 
            var result = await RunPythonScriptAsync(pythonExecutable, s_prBodyValidatorPath, [bodyPath]);
 
            Assert.NotEqual(0, result.ExitCode);
            Assert.Contains("exceeds GitHub's 65536-character pull request body limit", result.Output, StringComparison.Ordinal);
        }
        finally
        {
            Directory.Delete(tempDirectory.FullName, recursive: true);
        }
    }
 
    private async Task<string> GenerateDeterministicReleaseNotesAsync(string pythonExecutable, IEnumerable<string> commitLines, string? lineEnding = null)
    {
        Assert.True(File.Exists(s_releaseNotesGeneratorPath), $"Expected release notes generator at '{s_releaseNotesGeneratorPath}'.");
 
        var tempDirectory = Directory.CreateTempSubdirectory("extension-release-notes");
        try
        {
            var commitsPath = Path.Combine(tempDirectory.FullName, "commits.txt");
            var outputPath = Path.Combine(tempDirectory.FullName, "release_notes.md");
 
            await File.WriteAllTextAsync(
                commitsPath,
                string.Join(lineEnding ?? Environment.NewLine, commitLines) + (lineEnding ?? Environment.NewLine));
 
            var startInfo = new ProcessStartInfo(pythonExecutable)
            {
                WorkingDirectory = RepoRoot.Path,
                RedirectStandardError = true,
                RedirectStandardOutput = true,
                UseShellExecute = false,
            };
            startInfo.ArgumentList.Add(s_releaseNotesGeneratorPath);
            startInfo.ArgumentList.Add(commitsPath);
            startInfo.ArgumentList.Add(outputPath);
 
            using var process = Process.Start(startInfo)
                ?? throw new InvalidOperationException($"Failed to start {pythonExecutable}.");
 
            // Read both streams concurrently to avoid deadlock when a pipe buffer fills.
            var stdoutTask = process.StandardOutput.ReadToEndAsync();
            var stderrTask = process.StandardError.ReadToEndAsync();
            using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
 
            try
            {
                await process.WaitForExitAsync(timeout.Token);
            }
            catch (OperationCanceledException)
            {
                process.Kill(entireProcessTree: true);
                throw;
            }
 
            var stdout = await stdoutTask;
            var stderr = await stderrTask;
            testOutput.WriteLine(stdout);
            testOutput.WriteLine(stderr);
 
            Assert.True(
                process.ExitCode == 0,
                $"{pythonExecutable} exited with code {process.ExitCode}.{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}");
            return await File.ReadAllTextAsync(outputPath);
        }
        finally
        {
            Directory.Delete(tempDirectory.FullName, recursive: true);
        }
    }
 
    private async Task<CommandResult> RunPythonScriptAsync(string pythonExecutable, string scriptPath, IEnumerable<string> args)
    {
        Assert.True(File.Exists(scriptPath), $"Expected helper script at '{scriptPath}'.");
 
        using var process = new Process();
        process.StartInfo = new ProcessStartInfo(pythonExecutable)
        {
            WorkingDirectory = RepoRoot.Path,
            RedirectStandardError = true,
            RedirectStandardOutput = true,
            UseShellExecute = false,
        };
 
        process.StartInfo.ArgumentList.Add(scriptPath);
        foreach (var arg in args)
        {
            process.StartInfo.ArgumentList.Add(arg);
        }
 
        process.Start();
 
        // Read both streams concurrently to avoid deadlock when a helper prints diagnostics.
        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();
        using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
        await process.WaitForExitAsync(timeout.Token);
 
        var output = await stdoutTask + await stderrTask;
        testOutput.WriteLine(output);
 
        return new CommandResult(process.ExitCode, output);
    }
 
    private async Task<CommandResult> RunBashScriptAsync(string scriptPath, IEnumerable<string> args, IReadOnlyDictionary<string, string?> environment)
    {
        Assert.True(File.Exists(scriptPath), $"Expected helper script at '{scriptPath}'.");
 
        using var process = new Process();
        process.StartInfo = new ProcessStartInfo("bash")
        {
            WorkingDirectory = RepoRoot.Path,
            RedirectStandardError = true,
            RedirectStandardOutput = true,
            UseShellExecute = false,
        };
        process.StartInfo.ArgumentList.Add(scriptPath);
        foreach (var arg in args)
        {
            process.StartInfo.ArgumentList.Add(arg);
        }
 
        foreach (var (key, value) in environment)
        {
            process.StartInfo.Environment[key] = value ?? string.Empty;
        }
 
        process.Start();
 
        // Read both streams concurrently to avoid deadlock when bash emits diagnostics.
        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();
        using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
        await process.WaitForExitAsync(timeout.Token);
 
        var output = await stdoutTask + await stderrTask;
        testOutput.WriteLine(output);
 
        return new CommandResult(process.ExitCode, output);
    }
 
    private async Task<FakeGhFixture> CreateFakeGhAsync(string rootDirectory)
    {
        var binDirectory = Path.Combine(rootDirectory, "bin");
        Directory.CreateDirectory(binDirectory);
 
        var callLogPath = Path.Combine(rootDirectory, "gh-call-log.txt");
        var fakeGhPath = Path.Combine(binDirectory, "gh");
        await File.WriteAllTextAsync(
            fakeGhPath,
            """
            #!/usr/bin/env bash
            set -euo pipefail
 
            printf '%s\n' "$*" >> "${GH_CALL_LOG}"
 
            if [[ "$1" == "pr" && "$2" == "view" ]]; then
              if [[ "${GH_HAS_LABEL:-false}" == "true" ]]; then
                printf '%s\n' "vscode-extension-release"
              fi
              exit 0
            fi
 
            if [[ "$1" == "pr" && "$2" == "edit" && "$4" == "--remove-label" ]]; then
              exit "${GH_REMOVE_LABEL_EXIT_CODE:-0}"
            fi
 
            if [[ "$1" == "pr" && "$2" == "edit" && "$4" == "--add-label" ]]; then
              exit "${GH_ADD_LABEL_EXIT_CODE:-0}"
            fi
 
            exit 0
            """);
 
        var chmodResult = await RunBashCommandAsync($"chmod +x \"{fakeGhPath}\"");
        Assert.Equal(0, chmodResult.ExitCode);
 
        return new FakeGhFixture(callLogPath, $"{binDirectory}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
    }
 
    private async Task<CommandResult> RunBashCommandAsync(string command)
    {
        using var process = new Process();
        process.StartInfo = new ProcessStartInfo("bash")
        {
            WorkingDirectory = RepoRoot.Path,
            RedirectStandardError = true,
            RedirectStandardOutput = true,
            UseShellExecute = false,
        };
        process.StartInfo.ArgumentList.Add("-c");
        process.StartInfo.ArgumentList.Add(command);
 
        process.Start();
 
        // Read both streams concurrently to avoid deadlock when bash emits diagnostics.
        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();
        using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
        await process.WaitForExitAsync(timeout.Token);
 
        var output = await stdoutTask + await stderrTask;
        testOutput.WriteLine(output);
 
        return new CommandResult(process.ExitCode, output);
    }
 
    private sealed record FakeGhFixture(string CallLogPath, string PathEnvironment);
    private sealed record CommandResult(int ExitCode, string Output);
}