File: WorkflowScripts\ReportCiFailureIntegrationTests.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>
/// Integration tests for the reportFailure() and resolveSuccess() orchestrators in
/// .github/workflows/report-ci-failure.js, driven against an in-memory octokit fake
/// via report-ci-failure.integration.harness.js. These cover the find-or-create +
/// comment-dedup branching (reportFailure) and the find-and-close branching
/// (resolveSuccess) that the pure-helper tests cannot reach.
/// </summary>
public sealed class ReportCiFailureIntegrationTests : IDisposable
{
    private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web);
 
    // The harness reads the exact env-var name (REF); serialize the request verbatim
    // so the Web camelCase policy does not rename it.
    private static readonly JsonSerializerOptions s_requestOptions = new();
 
    private readonly TemporaryWorkspace _workspace;
    private readonly string _repoRoot;
    private readonly string _harnessPath;
    private readonly ITestOutputHelper _output;
 
    public ReportCiFailureIntegrationTests(ITestOutputHelper output)
    {
        _output = output;
        _workspace = TemporaryWorkspace.Create(output);
        _repoRoot = RepoRoot.Path;
        _harnessPath = Path.Combine(_repoRoot, "tests", "Infrastructure.Tests", "WorkflowScripts", "report-ci-failure.integration.harness.js");
    }
 
    public void Dispose() => _workspace.Dispose();
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ReportFailureFilesAutoCloseIssueAndRecordsRunAsComment()
    {
        var result = await InvokeAsync(new
        {
            operation = "reportFailure",
            env = new { REF = "main" },
        });
 
        Assert.False(result.Threw);
        var issue = Assert.Single(result.Issues);
        Assert.Equal("CI failing on `main`", issue.Title);
        Assert.Contains("ci-failure:ci.yml:push:main", issue.Body);
        Assert.Contains("<!-- autoclose:true -->", issue.Body);
        Assert.Equal(["automation-broken"], issue.Labels);
 
        var comment = Assert.Single(issue.Comments);
        Assert.Contains("/actions/runs/12345", comment);
        Assert.Contains("<!-- run:12345 -->", comment);
        Assert.DoesNotContain("/actions/runs/12345", issue.Body);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ReportFailureKeysSeparateIssuesPerBranch()
    {
        // A red `release/13.3` must not comment on `main`'s issue: the per-branch
        // marker keeps them distinct, so a fresh issue is filed.
        var result = await InvokeAsync(new
        {
            operation = "reportFailure",
            env = new { REF = "release/13.3" },
            issues = new[]
            {
                new { number = 4242, body = "<!-- ci-failure:ci.yml:push:main -->", state = "open" },
            },
        });
 
        Assert.Contains("create", result.Calls);
        var created = Assert.Single(result.Issues, issue => issue.Number != 4242);
        Assert.Contains("ci-failure:ci.yml:push:release/13.3", created.Body);
        var mainIssue = Assert.Single(result.Issues, issue => issue.Number == 4242);
        Assert.Empty(mainIssue.Comments);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ReportFailureCommentsOnExistingIssueForNewRun()
    {
        var first = await InvokeAsync(new { operation = "reportFailure", env = new { REF = "main" } });
        var issue = Assert.Single(first.Issues);
 
        var second = await InvokeAsync(new
        {
            operation = "reportFailure",
            env = new { REF = "main" },
            issues = new[] { new { number = issue.Number, body = issue.Body, state = "open", comments = issue.Comments } },
            runId = 67890,
            runNumber = 8,
        });
 
        var updated = Assert.Single(second.Issues);
        Assert.DoesNotContain("create", second.Calls);
        Assert.Equal(2, updated.Comments.Length);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ReportFailureCommentFailureLeavesRunUnrecorded()
    {
        var result = await InvokeAsync(new
        {
            operation = "reportFailure",
            env = new { REF = "main" },
            failComment = true,
        });
 
        Assert.True(result.Threw);
        var issue = Assert.Single(result.Issues);
        Assert.Empty(issue.Comments);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ResolveSuccessClosesTheBranchIssue()
    {
        var result = await InvokeAsync(new
        {
            operation = "resolveSuccess",
            env = new { REF = "main" },
            issues = new[]
            {
                new { number = 7, body = "lead <!-- ci-failure:ci.yml:push:main -->\n<!-- autoclose:true -->", state = "open" },
            },
        });
 
        Assert.False(result.Threw);
        var issue = Assert.Single(result.Issues);
        Assert.Equal("closed", issue.State);
        Assert.Equal("completed", issue.StateReason);
        // A closing comment is posted so the thread records why it closed.
        var comment = Assert.Single(issue.Comments);
        Assert.Contains("green again", comment);
        Assert.Equal(["update", "createComment"], result.Calls);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ResolveSuccessLeavesIssueOpenWhenAutoCloseStampIsMissing()
    {
        var result = await InvokeAsync(new
        {
            operation = "resolveSuccess",
            env = new { REF = "main" },
            issues = new[]
            {
                new { number = 7, body = "lead <!-- ci-failure:ci.yml:push:main -->", state = "open" },
            },
        });
 
        Assert.False(result.Threw);
        var issue = Assert.Single(result.Issues);
        Assert.Equal("open", issue.State);
        Assert.Empty(issue.Comments);
        Assert.Empty(result.Calls);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ResolveSuccessDoesNotCommentWhenCloseFails()
    {
        var result = await InvokeAsync(new
        {
            operation = "resolveSuccess",
            env = new { REF = "main" },
            failUpdate = true,
            issues = new[]
            {
                new { number = 7, body = "lead <!-- ci-failure:ci.yml:push:main -->\n<!-- autoclose:true -->", state = "open" },
            },
        });
 
        Assert.True(result.Threw);
        var issue = Assert.Single(result.Issues);
        Assert.Equal("open", issue.State);
        Assert.Empty(issue.Comments);
        Assert.Equal(["update"], result.Calls);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ResolveSuccessIsNoopWhenNoIssueOpen()
    {
        var result = await InvokeAsync(new
        {
            operation = "resolveSuccess",
            env = new { REF = "main" },
        });
 
        Assert.False(result.Threw);
        Assert.Empty(result.Issues);
        Assert.DoesNotContain("update", result.Calls);
        Assert.DoesNotContain("createComment", result.Calls);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task ResolveSuccessLeavesAnotherBranchIssueOpen()
    {
        // A green `main` push must not close `release/13.3`'s open issue.
        var result = await InvokeAsync(new
        {
            operation = "resolveSuccess",
            env = new { REF = "main" },
            issues = new[]
            {
                new { number = 9, body = "<!-- ci-failure:ci.yml:push:release/13.3 -->", state = "open" },
            },
        });
 
        var issue = Assert.Single(result.Issues);
        Assert.Equal("open", issue.State);
        Assert.DoesNotContain("update", result.Calls);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task TrackOpensIssueWhenCiRed()
    {
        var result = await InvokeAsync(new
        {
            operation = "track",
            env = new { REF = "main", CI_RED = "true", CI_GREEN = "false" },
        });
 
        Assert.False(result.Threw);
        var issue = Assert.Single(result.Issues);
        Assert.Equal("CI failing on `main`", issue.Title);
        Assert.Contains("create", result.Calls);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task TrackClosesIssueWhenCiGreen()
    {
        var result = await InvokeAsync(new
        {
            operation = "track",
            env = new { REF = "main", CI_RED = "false", CI_GREEN = "true" },
            issues = new[]
            {
                new { number = 7, body = "lead <!-- ci-failure:ci.yml:push:main -->\n<!-- autoclose:true -->", state = "open" },
            },
        });
 
        var issue = Assert.Single(result.Issues);
        Assert.Equal("closed", issue.State);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task TrackIsNoopWhenNeitherRedNorGreen()
    {
        // A skipped (no-relevant-changes) push: CI_RED and CI_GREEN both false, so a
        // still-open red-main issue must be left untouched (no CI signal).
        var result = await InvokeAsync(new
        {
            operation = "track",
            env = new { REF = "main", CI_RED = "false", CI_GREEN = "false" },
            issues = new[]
            {
                new { number = 7, body = "lead <!-- ci-failure:ci.yml:push:main -->", state = "open" },
            },
        });
 
        var issue = Assert.Single(result.Issues);
        Assert.Equal("open", issue.State);
        Assert.DoesNotContain("update", result.Calls);
        Assert.DoesNotContain("createComment", result.Calls);
    }
 
    private async Task<RunnerResult> InvokeAsync(object scenario)
    {
        var requestPath = Path.Combine(_workspace.Path, $"{Guid.NewGuid():N}.json");
        await File.WriteAllTextAsync(requestPath, JsonSerializer.Serialize(scenario, s_requestOptions));
 
        using var command = new NodeCommand(_output, "report-ci-failure-integration");
        command.WithWorkingDirectory(_repoRoot);
 
        var result = await command.ExecuteScriptAsync(_harnessPath, requestPath);
        Assert.Equal(0, result.ExitCode);
 
        var response = JsonSerializer.Deserialize<HarnessResponse>(result.Output, s_jsonOptions);
        Assert.NotNull(response);
        return response!.Result;
    }
 
    private sealed record HarnessResponse(RunnerResult Result);
 
    private sealed record RunnerResult(bool Threw, string[] Calls, RunnerIssue[] Issues);
 
    private sealed record RunnerIssue(int Number, string? Title, string State, string? StateReason, string Body, string[] Labels, string[] Comments);
}