File: WorkflowScripts\SelectTestsCommentScriptTests.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.Text.Json;
using Aspire.TestUtilities;
using Xunit;
 
namespace Infrastructure.Tests;
 
/// <summary>
/// Behavioral tests for the comment_selection job's inline github-script in
/// <c>.github/workflows/tests.yml</c> (the job that posts the "selected tests" PR comment). The
/// <see cref="Infrastructure.Tests.TestTriggerMap.SelectTestsWorkflowTests"/> content guards pin the
/// script's source text; these execute the *shipped* script against mocked github/context/core to
/// pin its behavior -- one comment per pushed commit (a new commit creates, a re-run of the same
/// commit updates in place), superseded comments collapsed via minimize (never deleted), the
/// head-SHA-over-context-SHA link precedence, and the skip-when-summary-missing path -- which content
/// matching cannot verify.
/// </summary>
public sealed class SelectTestsCommentScriptTests : IDisposable
{
    private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web);
 
    private readonly TemporaryWorkspace _workspace;
    private readonly string _repoRoot = RepoRoot.Path;
    private readonly string _harnessPath;
    private readonly ITestOutputHelper _output;
 
    public SelectTestsCommentScriptTests(ITestOutputHelper output)
    {
        _output = output;
        _workspace = TemporaryWorkspace.Create(output);
        _harnessPath = Path.Combine(_repoRoot, "tests", "Infrastructure.Tests", "WorkflowScripts", "select-tests-comment.harness.js");
    }
 
    public void Dispose() => _workspace.Dispose();
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task NewCommitCreatesCommentLinkingPrHeadAndMinimizesSuperseded()
    {
        var summaryPath = WriteSummary("## Tests selector (audit mode)\n\nSENTINEL-BODY-CONTENT");
        const string headSha = "abcdef1234567890abcdef1234567890abcdef12";
        const string olderSha = "1111111111111111111111111111111111111111";
 
        // A comment from an earlier commit exists; this is a brand-new commit.
        var older = MarkerComment(id: 41, nodeId: "NODE_OLDER", sha: olderSha);
        var result = await RunCommentScriptAsync(summaryPath, PullRequestContext(headSha, contextSha: "0000000fa11bac0fa11bac0fa11bac0fa11bac00"), older);
 
        var comment = Assert.Single(result.Created);
        Assert.Empty(result.Updated);
        Assert.Contains("<!-- select-tests-comment -->", comment.Body);
        Assert.Contains("SENTINEL-BODY-CONTENT", comment.Body);
        // Links the full head SHA and renders the 7-char short SHA; must not use the context.sha fallback.
        Assert.Contains($"/commit/{headSha}", comment.Body);
        Assert.Contains("[`abcdef1`]", comment.Body);
        Assert.DoesNotContain("0000000", comment.Body);
 
        // The older commit's comment is collapsed, not deleted.
        Assert.Equal(["NODE_OLDER"], result.Minimized);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task RerunOfSameCommitUpdatesInPlaceWithoutNewComment()
    {
        var summaryPath = WriteSummary("## Tests selector (audit mode)\n\nFRESH-CONTENT");
        const string headSha = "abcdef1234567890abcdef1234567890abcdef12";
        const string olderSha = "2222222222222222222222222222222222222222";
 
        // The comment for THIS commit already exists (a prior run of the same commit), plus an older one.
        var current = MarkerComment(id: 50, nodeId: "NODE_CURRENT", sha: headSha);
        var older = MarkerComment(id: 49, nodeId: "NODE_OLDER", sha: olderSha);
        var result = await RunCommentScriptAsync(summaryPath, PullRequestContext(headSha, contextSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), current, older);
 
        // Re-run updates the existing same-commit comment in place; no new comment is created.
        Assert.Empty(result.Created);
        var update = Assert.Single(result.Updated);
        Assert.Equal(50, update.CommentId);
        Assert.Contains("FRESH-CONTENT", update.Body);
 
        // The kept (current) comment is not minimized; the older commit's comment is.
        Assert.Equal(["NODE_OLDER"], result.Minimized);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task FallsBackToContextShaWhenNotPullRequest()
    {
        var summaryPath = WriteSummary("## Tests selector (audit mode)");
        const string contextSha = "1234567deadbeefdeadbeefdeadbeefdeadbeef0";
 
        // No pull_request on the payload (e.g. a non-PR trigger) -> the script uses context.sha.
        var result = await RunCommentScriptAsync(summaryPath, NonPullRequestContext(contextSha));
 
        var comment = Assert.Single(result.Created);
        Assert.Contains($"/commit/{contextSha}", comment.Body);
        Assert.Contains("[`1234567`]", comment.Body);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task SkipsCommentWhenSummaryFileMissing()
    {
        var missingPath = Path.Combine(_workspace.Path, "does-not-exist.md");
 
        var result = await RunCommentScriptAsync(missingPath, PullRequestContext("abcdef1234567890", contextSha: "fedcba0987654321"));
 
        Assert.Empty(result.Created);
        Assert.Empty(result.Updated);
        Assert.Empty(result.Minimized);
        Assert.Contains(result.Infos, info => info.Contains("skipping", StringComparison.OrdinalIgnoreCase));
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task MigratesLegacyStickyCommentByCreatingFreshAndCollapsingIt()
    {
        var summaryPath = WriteSummary("## Tests selector (audit mode)\n\nMIGRATED");
        const string headSha = "abcdef1234567890abcdef1234567890abcdef12";
 
        // The pre-migration state: a single legacy sticky comment (marker, no commit footer). It
        // matches no SHA, so the run posts a fresh per-commit comment and collapses the legacy one.
        var legacy = LegacyMarkerComment(id: 7, nodeId: "NODE_LEGACY");
        var result = await RunCommentScriptAsync(summaryPath, PullRequestContext(headSha, contextSha: headSha), legacy);
 
        var comment = Assert.Single(result.Created);
        Assert.Contains($"/commit/{headSha}", comment.Body);
        Assert.Empty(result.Updated);
        Assert.Equal(["NODE_LEGACY"], result.Minimized);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task NewCommitMinimizesAllSupersededComments()
    {
        var summaryPath = WriteSummary("## Tests selector (audit mode)");
        const string headSha = "abcdef1234567890abcdef1234567890abcdef12";
 
        // Several superseded comments from earlier commits -> all collapsed, none kept.
        var older1 = MarkerComment(id: 11, nodeId: "NODE_1", sha: "1111111111111111111111111111111111111111");
        var older2 = MarkerComment(id: 12, nodeId: "NODE_2", sha: "2222222222222222222222222222222222222222");
        var older3 = MarkerComment(id: 13, nodeId: "NODE_3", sha: "3333333333333333333333333333333333333333");
        var result = await RunCommentScriptAsync(summaryPath, PullRequestContext(headSha, contextSha: headSha), older1, older2, older3);
 
        Assert.Single(result.Created);
        Assert.Equal(3, result.Minimized.Length);
        Assert.Contains("NODE_1", result.Minimized);
        Assert.Contains("NODE_2", result.Minimized);
        Assert.Contains("NODE_3", result.Minimized);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task StaleRerunUpdatesOwnCommentButMinimizesNothing()
    {
        var summaryPath = WriteSummary("## Tests selector (audit mode)\n\nSTALE-REFRESH");
        const string staleSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
        const string liveSha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
 
        // A manual re-run of the run for staleSha after a newer commit (liveSha) already posted. The
        // run's payload still carries staleSha; pulls.get reports liveSha as the head. It must refresh
        // its own comment but NOT minimize -- otherwise it would collapse the newer commit's live one.
        var ownComment = MarkerComment(id: 60, nodeId: "NODE_OWN", sha: staleSha);
        var liveComment = MarkerComment(id: 61, nodeId: "NODE_LIVE", sha: liveSha);
        var result = await RunCommentScriptAsync(
            summaryPath,
            PullRequestContext(staleSha, contextSha: staleSha),
            liveHeadSha: liveSha,
            [ownComment, liveComment]);
 
        Assert.Empty(result.Created);
        var update = Assert.Single(result.Updated);
        Assert.Equal(60, update.CommentId);
        Assert.Empty(result.Minimized);
    }
 
    private string WriteSummary(string content)
    {
        var path = Path.Combine(_workspace.Path, $"{Guid.NewGuid():N}.md");
        File.WriteAllText(path, content);
        return path;
    }
 
    // A prior select-tests comment as the REST listing returns it: the marker plus a footer linking
    // its commit (the body the script matches on for idempotency).
    private static object MarkerComment(int id, string nodeId, string sha) => new
    {
        id,
        node_id = nodeId,
        body = $"<!-- select-tests-comment -->\n## Tests selector (audit mode)\n\n---\n_Selection computed for commit [`{sha[..7]}`](https://github.com/microsoft/aspire/commit/{sha})._",
    };
 
    // The legacy single sticky comment from before per-commit posting: it has the marker but no
    // commit footer, so it matches no SHA and must be collapsed on migration.
    private static object LegacyMarkerComment(int id, string nodeId) => new
    {
        id,
        node_id = nodeId,
        body = "<!-- select-tests-comment -->\n## Tests selector (audit mode)\n\n_Legacy sticky comment with no commit footer._",
    };
 
    private static object PullRequestContext(string headSha, string contextSha) => new
    {
        repo = new { owner = "microsoft", repo = "aspire" },
        issue = new { number = 18127 },
        payload = new { pull_request = new { head = new { sha = headSha } } },
        sha = contextSha,
        serverUrl = "https://github.com",
    };
 
    private static object NonPullRequestContext(string contextSha) => new
    {
        repo = new { owner = "microsoft", repo = "aspire" },
        issue = new { number = 18127 },
        payload = new { },
        sha = contextSha,
        serverUrl = "https://github.com",
    };
 
    private async Task<CommentScriptResult> RunCommentScriptAsync(string commentFile, object context, params object[] existingComments)
        => await RunCommentScriptAsync(commentFile, context, liveHeadSha: null, existingComments);
 
    // liveHeadSha overrides the PR head the script sees via pulls.get (the minimize gate). Passing a
    // value different from this run's commit simulates a stale re-run (an older run replayed after a
    // newer commit). null -> the harness defaults it to this run's head, i.e. "this run is live".
    private async Task<CommentScriptResult> RunCommentScriptAsync(string commentFile, object context, string? liveHeadSha, object[] existingComments)
    {
        var script = ExtractCommentScript();
        var requestPath = Path.Combine(_workspace.Path, $"{Guid.NewGuid():N}.json");
        var outputPath = Path.Combine(_workspace.Path, $"{Guid.NewGuid():N}.result.json");
        await File.WriteAllTextAsync(requestPath, JsonSerializer.Serialize(new { script, commentFile, context, liveHeadSha, existingComments }, s_jsonOptions));
 
        using var command = new NodeCommand(_output, "select-tests-comment");
        command.WithWorkingDirectory(_repoRoot);
 
        // Result goes to a file, not stdout, so a stray node warning on stderr/stdout can't corrupt
        // the JSON (NodeCommand merges both streams into result.Output).
        var result = await command.ExecuteScriptAsync(_harnessPath, requestPath, outputPath);
        Assert.Equal(0, result.ExitCode);
 
        var response = JsonSerializer.Deserialize<HarnessResponse<CommentScriptResult>>(await File.ReadAllTextAsync(outputPath), s_jsonOptions);
        Assert.NotNull(response);
        return response!.Result;
    }
 
    // Extracts the shipped github-script body from the comment_selection job's `script: |` block so
    // the test exercises the exact text that runs in CI (it can't be required as a module -- see the
    // harness header for why). Dedents the YAML block scalar by its common indent.
    private string ExtractCommentScript()
    {
        var lines = File.ReadAllText(Path.Combine(_repoRoot, ".github", "workflows", "tests.yml"))
            .Replace("\r\n", "\n")
            .Split('\n');
 
        var jobIdx = Array.FindIndex(lines, l => l.Contains("comment_selection:", StringComparison.Ordinal));
        Assert.True(jobIdx >= 0, "Expected a comment_selection job in tests.yml.");
 
        var scriptIdx = Array.FindIndex(lines, jobIdx, l => l.TrimEnd().EndsWith("script: |", StringComparison.Ordinal));
        Assert.True(scriptIdx >= 0, "Expected a 'script: |' block in the comment_selection job.");
 
        // The block runs from the first deeper-indented line until indentation returns to the
        // `script:` key's column (or shallower).
        var keyIndent = IndentOf(lines[scriptIdx]);
        var body = new List<string>();
        for (var i = scriptIdx + 1; i < lines.Length; i++)
        {
            var line = lines[i];
            if (line.Trim().Length == 0)
            {
                body.Add(string.Empty);
                continue;
            }
 
            if (IndentOf(line) <= keyIndent)
            {
                break;
            }
 
            body.Add(line);
        }
 
        while (body.Count > 0 && body[^1].Length == 0)
        {
            body.RemoveAt(body.Count - 1);
        }
 
        Assert.NotEmpty(body);
        var minIndent = body.Where(l => l.Length > 0).Min(IndentOf);
        return string.Join("\n", body.Select(l => l.Length >= minIndent ? l[minIndent..] : l));
    }
 
    private static int IndentOf(string line) => line.Length - line.TrimStart().Length;
 
    private sealed record HarnessResponse<T>(T Result);
 
    private sealed record CommentScriptResult(CreatedComment[] Created, UpdatedComment[] Updated, string[] Minimized, string[] Infos);
 
    private sealed record CreatedComment(string Body);
 
    private sealed record UpdatedComment(int CommentId, string Body);
}