File: WorkflowScripts\ReportPipelineFailureTests.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>
/// Tests for the pure helpers in
/// .github/workflows/report-pipeline-failure.js.
/// </summary>
public sealed class ReportPipelineFailureTests : IDisposable
{
    private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web);
 
    private readonly TemporaryWorkspace _workspace;
    private readonly string _repoRoot;
    private readonly string _harnessPath;
    private readonly ITestOutputHelper _output;
 
    public ReportPipelineFailureTests(ITestOutputHelper output)
    {
        _output = output;
        _workspace = TemporaryWorkspace.Create(output);
        _repoRoot = RepoRoot.Path;
        _harnessPath = Path.Combine(_repoRoot, "tests", "Infrastructure.Tests", "WorkflowScripts", "report-pipeline-failure.harness.js");
    }
 
    public void Dispose() => _workspace.Dispose();
 
    [Theory]
    [InlineData("deployment-tests.yml", "<!-- ci-failure:deployment-tests.yml:scheduled -->")]
    [InlineData("tests-daily-smoke.yml", "<!-- ci-failure:tests-daily-smoke.yml:scheduled -->")]
    [RequiresTools(["node"])]
    public async Task BuildMarkerEmbedsWorkflowFile(string file, string expected)
    {
        var marker = await InvokeHarnessAsync<string>("buildMarker", new { workflowFile = file });
 
        Assert.Equal(expected, marker);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task BuildIssueTitleIsNeutral()
    {
        // The title must not pre-classify the failure as test-vs-infra: a nightly
        // deployment/smoke failure can be either, and these pipelines do not inspect
        // results to tell them apart.
        var title = await InvokeHarnessAsync<string>("buildIssueTitle", new { displayName = "Deployment E2E Tests" });
 
        Assert.Equal("Nightly run failing: Deployment E2E Tests", title);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task BuildIssueBodyIsStaticDescriptionWithMarker()
    {
        // Each failed run is recorded as a comment, so the body is a fixed
        // description: the marker (for lookup) plus prose, with no per-run table.
        var body = await InvokeHarnessAsync<string>(
            "buildIssueBody",
            new
            {
                marker = "<!-- ci-failure:deployment-tests.yml:scheduled -->",
                displayName = "Deployment E2E Tests",
                workflowFile = "deployment-tests.yml",
            });
 
        Assert.Contains("<!-- ci-failure:deployment-tests.yml:scheduled -->", body);
        Assert.Contains("is failing on its nightly run", body);
        Assert.Contains("comment below", body);
        Assert.DoesNotContain("ci-failure-runs:begin", body);
        Assert.DoesNotContain("[run #", body);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task BuildIssueBodyEmbedsCcWhenProvided()
    {
        // The first filing notifies a team via an @mention in the body; subsequent
        // failures notify via comment.
        var withCc = await InvokeHarnessAsync<string>(
            "buildIssueBody",
            new
            {
                marker = "<!-- ci-failure:deployment-tests.yml:scheduled -->",
                displayName = "Deployment E2E Tests",
                workflowFile = "deployment-tests.yml",
                cc = "@microsoft/aspire-team",
            });
        Assert.Contains("/cc @microsoft/aspire-team", withCc);
 
        var withoutCc = await InvokeHarnessAsync<string>(
            "buildIssueBody",
            new
            {
                marker = "<!-- ci-failure:tests-daily-smoke.yml:scheduled -->",
                displayName = "Daily CLI Smoke Tests",
                workflowFile = "tests-daily-smoke.yml",
            });
        Assert.DoesNotContain("/cc", withoutCc);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task FormatCommentReferencesRunAndCommit()
    {
        var comment = await InvokeHarnessAsync<string>(
            "formatComment",
            new { run = new { runNumber = 7, runUrl = "https://x/runs/7", sha = "abcdef0123456789" } });
 
        Assert.Contains("[run #7](https://x/runs/7)", comment);
        Assert.Contains("`abcdef01`", comment);
    }
 
    [Fact]
    [RequiresTools(["node"])]
    public async Task FormatCommentAppendsDetailWhenProvided()
    {
        // Per-run caller detail (e.g. the smoke suite's CLI versions) rides on the
        // failure comment that records the run.
        var comment = await InvokeHarnessAsync<string>(
            "formatComment",
            new
            {
                run = new { runNumber = 7, runUrl = "https://x/runs/7", sha = "abcdef0123456789" },
                detail = "### Aspire CLI versions tested\n\n- 9.0.0"
            });
 
        Assert.Contains("[run #7](https://x/runs/7)", comment);
        Assert.Contains("### Aspire CLI versions tested", comment);
        Assert.Contains("- 9.0.0", comment);
    }
 
    private async Task<T> InvokeHarnessAsync<T>(string operation, object payload)
    {
        var requestPath = Path.Combine(_workspace.Path, $"{Guid.NewGuid():N}.json");
        await File.WriteAllTextAsync(requestPath, JsonSerializer.Serialize(new { operation, payload }, s_jsonOptions));
 
        using var command = new NodeCommand(_output, "report-pipeline-failure");
        command.WithWorkingDirectory(_repoRoot);
 
        var result = await command.ExecuteScriptAsync(_harnessPath, requestPath);
        Assert.Equal(0, result.ExitCode);
 
        var response = JsonSerializer.Deserialize<HarnessResponse<T>>(result.Output, s_jsonOptions);
        Assert.NotNull(response);
        return response!.Result;
    }
 
    private sealed record HarnessResponse<T>(T Result);
}