File: AnalyzeCiFailureOccurrenceTests.cs
Web Access
Project: src\tests\AnalyzeCiFailure.Tests\AnalyzeCiFailure.Tests.csproj (AnalyzeCiFailure.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;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using AnalyzeCiFailure;
using Aspire.TestUtilities;
using VerifyXunit;
using Xunit;
 
namespace Infrastructure.Tests;
 
/// <summary>Verifies cause occurrence merging, replay identity and preservation of operator-owned content.</summary>
public sealed partial class AnalyzeCiFailureWorkflowTests
{
    private const string StoredOccurrenceRow = "| 2026-10-20 | [122](https://github.com/microsoft/aspire/actions/runs/122) | ` Original job ` | #42 |";
    private const string CurrentOccurrenceRow = "| 2026-10-21 | [123](https://github.com/microsoft/aspire/actions/runs/123) | ` Tests ` | #42 |";
    private const string OperatorSuffix = "\nOperator suffix: preserve 日本語.\nSecond operator line.\n";
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ManagedOccurrenceAppendPreservesOperatorPrefixAndSuffix(bool crlf)
    {
        var body = ExistingGroupedIssue.Replace(
            "<!-- ci-failure-occurrences:end -->",
            StoredOccurrenceRow + "\n<!-- ci-failure-occurrences:end -->",
            StringComparison.Ordinal) + "\n" + OperatorSuffix;
        if (crlf)
        {
            body = body.ReplaceLineEndings("\r\n");
        }
 
        var updated = CauseIssueRenderer.AppendOccurrence(body, CurrentOccurrenceRow, total: 2);
 
        Assert.StartsWith(body[..body.IndexOf("<!-- ci-failure-occurrences:start -->", StringComparison.Ordinal)], updated, StringComparison.Ordinal);
        Assert.EndsWith(crlf ? OperatorSuffix.ReplaceLineEndings("\r\n") : OperatorSuffix, updated, StringComparison.Ordinal);
        await Verifier.Verify(updated, "md").UseDirectory("Snapshots").UseParameters(crlf);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ManagedOccurrenceMainMigrationPreservesOperatorPrefixAndSuffix(bool crlf)
    {
        var body = ExistingGroupedIssue
            .Replace("flaky-test", "main-repository-breakage", StringComparison.Ordinal)
            .Replace("Operator notes:", "Old generated attribution.\n**Type**: main-repository-breakage\n\nOperator notes:", StringComparison.Ordinal)
            .Replace("<!-- ci-failure-occurrences:end -->", StoredOccurrenceRow + "\n<!-- ci-failure-occurrences:end -->", StringComparison.Ordinal)
            + "\n" + OperatorSuffix;
        var canonical = body.Replace("Old generated attribution.", "Trusted generated details.", StringComparison.Ordinal)
            .Replace("Operator notes: keep this.", "", StringComparison.Ordinal);
        if (crlf)
        {
            body = body.ReplaceLineEndings("\r\n");
        }
 
        var updated = CauseIssueRenderer.MigrateMainBody(body, canonical);
 
        Assert.EndsWith(crlf ? OperatorSuffix.ReplaceLineEndings("\r\n") : OperatorSuffix, updated, StringComparison.Ordinal);
        await Verifier.Verify(updated, "md").UseDirectory("Snapshots").UseParameters(crlf);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ManagedOccurrenceFittingCountsSuffixWithoutTrimmingOperatorText(bool observations)
    {
        var oldest = StoredOccurrenceRow.Replace("Original job", new string('x', count: 1000), StringComparison.Ordinal);
        var suffix = OperatorSuffix + new string('語', count: 80) + "\n";
        var body = ExistingGroupedIssue
            .Replace("Operator notes: keep this.\n", "Operator notes: keep this.\n\n", StringComparison.Ordinal)
            .Replace("## Occurrences\n\n", "## Occurrences\n\nShowing 2 most recent of 2 occurrences.\n\n", StringComparison.Ordinal)
            .Replace("<!-- ci-failure-occurrences:end -->", oldest + "\n" + CurrentOccurrenceRow + "\n<!-- ci-failure-occurrences:end -->", StringComparison.Ordinal)
            + "\n" + suffix;
        JsonObject[] tests = observations
            ? [new JsonObject { ["name"] = new string('t', count: 500), ["job_id"] = 123, ["job"] = "Tests" }]
            : [];
        var budget = Encoding.UTF8.GetByteCount(body) - Encoding.UTF8.GetByteCount(oldest) - 1 + (observations ? 200 : 0);
 
        var updated = CauseIssueRenderer.FitIssue(body, tests, budget);
 
        Assert.NotNull(updated);
        Assert.InRange(Encoding.UTF8.GetByteCount(updated), 1, budget);
        Assert.EndsWith(suffix, updated, StringComparison.Ordinal);
        Assert.Equal([CurrentOccurrenceRow], updated.Split('\n').Where(line => line.StartsWith("| 2026-", StringComparison.Ordinal)));
        Assert.Null(CauseIssueRenderer.FitIssue(body, tests, Encoding.UTF8.GetByteCount(updated) - 1));
        await Verifier.Verify(updated, "md").UseDirectory("Snapshots").UseParameters(observations);
    }
 
    [Theory]
    [InlineData("duplicate-start")]
    [InlineData("duplicate-end")]
    [InlineData("reversed")]
    [InlineData("inside-notes")]
    [InlineData("legacy-trailing-notes")]
    public void ManagedOccurrenceAppendRejectsAmbiguousOrUnrecognizedRegion(string shape)
    {
        var body = shape switch
        {
            "duplicate-start" => ExistingGroupedIssue.Replace("## Occurrences", "<!-- ci-failure-occurrences:start -->\n## Occurrences", StringComparison.Ordinal),
            "duplicate-end" => ExistingGroupedIssue + "\n<!-- ci-failure-occurrences:end -->",
            "reversed" => ExistingGroupedIssue.Replace("occurrences:start", "occurrences:temporary", StringComparison.Ordinal)
                .Replace("occurrences:end", "occurrences:start", StringComparison.Ordinal)
                .Replace("occurrences:temporary", "occurrences:end", StringComparison.Ordinal),
            "inside-notes" => ExistingGroupedIssue.Replace("<!-- ci-failure-occurrences:end -->", "Operator content inside.\n<!-- ci-failure-occurrences:end -->", StringComparison.Ordinal),
            _ => ExistingGroupedIssue.Replace("<!-- ci-failure-occurrences:start -->\n", "", StringComparison.Ordinal)
                .Replace("<!-- ci-failure-occurrences:end -->", "Un-delimited trailing notes.", StringComparison.Ordinal),
        };
 
        var error = Assert.Throws<ValidationException>(() => CauseIssueRenderer.AppendOccurrence(body, CurrentOccurrenceRow, total: 1));
 
        Assert.Equal(
            shape is "duplicate-start" or "duplicate-end" or "reversed"
            ? "ambiguous managed occurrence section" : "unsupported occurrence section contents",
            error.Message);
    }
 
    [Theory]
    [InlineData("prefix")]
    [InlineData("suffix")]
    [InlineData("managed")]
    [InlineData("managed-with-notes")]
    [RequiresTools(["bash", "jq", "git"])]
    public async Task ManagedOccurrenceReplayGuardIgnoresHumanLinksOutsideRegion(string location)
    {
        await WriteGroupedFixtureAsync();
        const string link = "Human investigation: [123](https://github.com/microsoft/aspire/actions/runs/123).";
        var body = ExistingGroupedIssue;
        var expectedRow = CurrentOccurrenceRow;
        if (location.StartsWith("managed", StringComparison.Ordinal))
        {
            expectedRow = CurrentOccurrenceRow.Replace("` Tests `", "` Already displayed job `", StringComparison.Ordinal);
            body = body.Replace("<!-- ci-failure-occurrences:end -->", expectedRow + "\n<!-- ci-failure-occurrences:end -->", StringComparison.Ordinal);
        }
        if (location is "prefix" or "managed-with-notes")
        {
            body = body.Replace("Operator notes: keep this.", "Operator notes: keep this.\n" + link, StringComparison.Ordinal);
        }
        if (location is "suffix" or "managed-with-notes")
        {
            body += "\n\n" + link + "\n" + OperatorSuffix;
        }
 
        var result = await RunSuccessfulGroupedPublicationAsync(body: body);
 
        Assert.True(result.ExitCode == 0, result.Output);
        var path = Path.Combine(_workspace.Path, "issue-body.md");
        Assert.True(File.Exists(path), result.Output);
        var updated = await File.ReadAllTextAsync(path);
        Assert.Equal([expectedRow], updated.Split('\n').Where(line => line.StartsWith("| 2026-", StringComparison.Ordinal)));
        await Verifier.Verify(updated, "md").UseDirectory("Snapshots").UseParameters(location);
    }
 
    [Theory]
    [InlineData("pull-request")]
    [InlineData("main")]
    [RequiresTools(["bash", "jq", "git"])]
    public async Task ManagedOccurrenceReplayRestoresFirstStoredJobDateUrlAndSubject(string replayScope)
    {
        await WriteGroupedFixtureAsync();
        var first = await RunSuccessfulGroupedPublicationAsync();
        Assert.True(first.ExitCode == 0, first.Output);
        var bodyPath = Path.Combine(_workspace.Path, "issue-body.md");
        var body = await File.ReadAllTextAsync(bodyPath);
        var originalRow = Assert.Single(body.Split('\n'), line => line.StartsWith("| 2026-", StringComparison.Ordinal));
        var firstHistory = await RunProcessAsync(
            "git",
            ["--no-pager", "--git-dir", "remote.git",
            "show", "refs/heads/memory/ci-failure-analysis:causes/debugger-timeout.json"]);
        Assert.Equal(0, firstHistory.ExitCode);
        await File.WriteAllTextAsync(
            Path.Combine(_workspace.Path, "issue.json"),
            new JsonArray(new JsonObject
            {
                ["number"] = 77,
                ["state"] = "open",
                ["body"] = body.Replace(originalRow + "\n", "", StringComparison.Ordinal),
                ["labels"] = new JsonArray(new JsonObject { ["name"] = "ci-failure-cause" }),
            }).ToJsonString());
        File.Delete(bodyPath);
        await File.WriteAllTextAsync(
            Path.Combine(EvidenceDirectory, "diagnostics/failed-jobs.json"),
            """[{"id":123,"name":"Renamed trusted job"}]""");
        var trustedPath = Path.Combine(EvidenceDirectory, "test-failures.json");
        var collected = JsonNode.Parse(await File.ReadAllTextAsync(trustedPath))!;
        foreach (var test in collected.AsArray())
        {
            test!["job"] = "Renamed trusted job";
            test["error"] = "Changed trusted evidence";
        }
        await File.WriteAllTextAsync(trustedPath, collected.ToJsonString());
        var summaryPath = Path.Combine(_workspace.Path, "ci-analysis-output", "analysis-result.json");
        var summary = JsonNode.Parse(await File.ReadAllTextAsync(summaryPath))!;
        foreach (var test in summary["failed_tests"]!.AsArray())
        {
            test!["job"] = "Renamed trusted job";
            test["error"] = "Changed analyzer evidence";
            test["reason"] = "New analyzer explanation";
        }
        summary["run_scope"] = replayScope;
        summary["pr"] = replayScope == "main" ? null : new JsonObject { ["number"] = 43 };
        await File.WriteAllTextAsync(summaryPath, summary.ToJsonString());
        await File.WriteAllTextAsync(
            Path.Combine(EvidenceDirectory, "run-context.json"),
            new JsonObject { ["run_id"] = 123, ["run_scope"] = replayScope, ["pr_numbers"] = replayScope == "main" ? "" : "43" }.ToJsonString());
        var fakeGh = Path.Combine(Assert.Single(Directory.GetDirectories(_workspace.Path, "fake-bin-*")), "gh");
 
        var replay = await RunPublicationCommandAsync(fakeGh, "2026-10-23T12:00:00Z");
 
        Assert.True(replay.ExitCode == 0, replay.Output);
        Assert.True(File.Exists(bodyPath), replay.Output);
        var updated = await File.ReadAllTextAsync(bodyPath);
        var restoredRow = Assert.Single(updated.Split('\n'), line => line.StartsWith("| 2026-", StringComparison.Ordinal));
        Assert.Equal(originalRow, restoredRow);
        var replayHistory = await RunProcessAsync(
            "git",
            ["--no-pager", "--git-dir", "remote.git",
            "show", "refs/heads/memory/ci-failure-analysis:causes/debugger-timeout.json"]);
        Assert.Equal(0, replayHistory.ExitCode);
        Assert.True(
            JsonNode.DeepEquals(JsonNode.Parse(firstHistory.Output), JsonNode.Parse(replayHistory.Output)),
            "Replay must leave the first stored occurrence and its trusted diagnostics unchanged.");
        await Verifier.Verify(
            new JsonObject
            {
                ["row"] = restoredRow,
                ["occurrences"] = JsonNode.Parse(replayHistory.Output)!["occurrences"]!.DeepClone(),
            }.ToJsonString(new JsonSerializerOptions
            {
                WriteIndented = true,
                Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            }),
            "json").UseDirectory("Snapshots").UseParameters(replayScope);
    }
}