File: AnalyzeCiFailureInvocationTests.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 AnalyzeCiFailure;
using Aspire.TestUtilities;
using Xunit;
 
namespace Infrastructure.Tests;
 
/// <summary>Checks operation input boundaries, independent output lifetimes and offline replay behavior.</summary>
public sealed partial class AnalyzeCiFailureWorkflowTests
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    [SkipOnPlatform(TestPlatforms.Windows, "Creating directory symlinks requires elevated privileges on Windows.")]
    public async Task HistoryCheckoutValidationResolvesInputAndDestinationAliases(bool historyThroughLink)
    {
        var evidence = Directory.CreateDirectory(Path.Combine(_workspace.Path, "saved-evidence")).FullName;
        var proposal = Directory.CreateDirectory(Path.Combine(_workspace.Path, "saved-proposal")).FullName;
        var alias = Path.Combine(_workspace.Path, "evidence-alias");
        Directory.CreateSymbolicLink(alias, evidence);
        var input = historyThroughLink ? evidence : alias;
        var checkout = Path.Combine(historyThroughLink ? alias : evidence, "history-checkout");
 
        var result = await RunValidatorCommandAsync(
            ["publish", "--repo", "microsoft/aspire", "--evidence-dir", input,
                "--analysis-dir", proposal, "--history-dir", checkout]);
 
        Assert.Equal(1, result.ExitCode);
        Assert.Contains("--history-dir must be outside", result.Output, StringComparison.Ordinal);
        Assert.Empty(Directory.GetFileSystemEntries(evidence));
        Assert.Empty(Directory.GetFileSystemEntries(proposal));
    }
 
    [Theory]
    [InlineData("valid", 0, false)]
    [InlineData("missing-artifact", 0, true)]
    [InlineData("history-transport", 1, true)]
    [RequiresTools(["bash", "git"])]
    public async Task WorkflowArtifactInputsSurviveScratchCleanup(
        string scenario,
        int expectedExitCode,
        bool recoveryExpected)
    {
        var result = await RunCollectionAsync(scenario, useWorkflowAdapter: true);
 
        Assert.Equal(expectedExitCode, result.ExitCode);
        var outputs = await File.ReadAllTextAsync(Path.Combine(_workspace.Path, "workflow-output"));
        var evidence = ReadNamedPath(outputs, "evidence_dir");
        var recovery = ReadNamedPath(outputs, "recovery_dir");
        var diagnostics = ReadNamedPath(outputs, "diagnostics_dir");
        var workspace = ReadNamedPath(outputs, "workspace_dir");
        Assert.Equal(EvidenceDirectory, evidence);
        Assert.True(File.Exists(Path.Combine(evidence, "collection-outcome.json")));
        Assert.True(File.Exists(Path.Combine(diagnostics, "summary.json")));
        Assert.False(Directory.Exists(Path.Combine(workspace, "scratch")));
        Assert.False(Directory.Exists(Path.Combine(_workspace.Path, "ci-failure-data")));
        if (expectedExitCode == 0)
        {
            Assert.True(File.Exists(Path.Combine(evidence, "evidence.json")));
            Assert.True(File.Exists(Path.Combine(evidence, "analysis-summary.md")));
            Assert.True(File.Exists(Path.Combine(evidence, "diagnostics", "job-77.log")));
        }
        Assert.Equal(recoveryExpected, File.Exists(Path.Combine(recovery, "manifest.json")));
        if (recoveryExpected)
        {
            Assert.True(File.Exists(Path.Combine(recovery, "diagnostics", "job-77.log")));
        }
    }
 
    [Fact]
    [RequiresTools(["bash", "git"])]
    public async Task GeneratedDiagnosticJsonUsesNativeLineEnding()
    {
        var result = await RunCollectionAsync();
 
        Assert.Equal(0, result.ExitCode);
        var json = await File.ReadAllTextAsync(Path.Combine(EvidenceDirectory, "diagnostics", "run.json"));
        Assert.EndsWith(Environment.NewLine, json);
        using var document = System.Text.Json.JsonDocument.Parse(json);
        Assert.Equal(123, document.RootElement.GetProperty("id").GetInt64());
    }
 
    [Theory]
    [InlineData("collect", "--run-id", "--evidence-dir")]
    [InlineData("replay", "--evidence-dir", "--run-id")]
    public async Task CollectionAndReplayExposeDistinctInputContracts(string command, string supported, string unsupported)
    {
        var help = await RunValidatorCommandAsync([command, "--help"]);
 
        Assert.Equal(0, help.ExitCode);
        Assert.Contains(supported, help.Output, StringComparison.Ordinal);
        var invalid = await RunValidatorCommandAsync([command, unsupported, "input"]);
        Assert.Equal(2, invalid.ExitCode);
        Assert.Contains($"Unrecognized command or argument '{unsupported}'", invalid.Output, StringComparison.Ordinal);
        Assert.Empty(Directory.GetFileSystemEntries(_workspace.Path));
    }
 
    [Theory]
    [InlineData("--repo", "collect", "--run-id", "123")]
    [InlineData("--run-id", "collect", "--repo", "microsoft/aspire")]
    [InlineData("--evidence-dir", "replay")]
    public async Task OperationSpecificInputsAreRequiredBeforeAcquisition(string missing, params string[] arguments)
    {
        var result = await RunProcessAsync(
            Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet",
            ["exec", "--runtimeconfig", Path.ChangeExtension(typeof(AnalyzeCiFailureWorkflowTests).Assembly.Location, ".runtimeconfig.json"),
                typeof(AnalyzeCiFailure.Program).Assembly.Location, .. arguments]);
 
        Assert.Equal(2, result.ExitCode);
        Assert.Contains($"Option '{missing}' is required.", result.Output, StringComparison.Ordinal);
        Assert.Empty(Directory.GetFileSystemEntries(_workspace.Path));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    [RequiresTools(["bash", "git"])]
    public async Task UnavailableTestResultsAllowCollectionAndReplayButNotValidation(bool useWorkflowAdapter)
    {
        var collection = await RunCollectionAsync("missing-artifact", useWorkflowAdapter: useWorkflowAdapter);
 
        Assert.Equal(0, collection.ExitCode);
        var input = new EvidencePaths(EvidenceDirectory);
        var original = await File.ReadAllTextAsync(input.Bundle);
        var evidence = TransientJson.Read<EvidenceBundle>(original);
        Assert.Equal(EvidenceState.Unavailable, evidence.TestEvidence.State);
        Assert.Empty(evidence.TestEvidence.Failures);
        Assert.NotEmpty(evidence.TestEvidence.Problems);
 
        var replay = await RunValidatorCommandAsync(["replay", "--evidence-dir", input.RootDirectory]);
 
        Assert.Equal(0, replay.ExitCode);
        var output = new EvidencePaths(ReadNamedPath(replay.Output, "replay_dir"));
        var replayed = TransientJson.Read<EvidenceBundle>(await File.ReadAllTextAsync(output.Bundle));
        Assert.Equal(EvidenceState.Unavailable, replayed.TestEvidence.State);
        Assert.Equal(original, await File.ReadAllTextAsync(input.Bundle));
        Assert.Equal("degraded", TransientJson.Read<AnalysisOutcome>(await File.ReadAllTextAsync(output.Outcome)).Status);
        var error = Assert.Throws<ValidationException>(() =>
            AnalysisValidator.Validate(replayed, AnalyzeCiFailureFixtures.CreateInfrastructureProposal(replayed), "microsoft/aspire"));
        Assert.Contains("publication and reruns are not authorized", error.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task RepeatedFailedInvocationsRetainIndependentOutputsWithoutResettingPreviousFiles()
    {
        var first = await RunValidatorCommandAsync(["collect", "--repo", "microsoft/aspire", "--run-id", "invalid"]);
        Assert.Equal(1, first.ExitCode);
        var firstRoot = ReadNamedPath(first.Output, "workspace_dir");
        var firstOutcome = Path.Combine(firstRoot, "evidence", "collection-outcome.json");
        var bytes = await File.ReadAllBytesAsync(firstOutcome);
        await File.WriteAllTextAsync(Path.Combine(firstRoot, "operator-note.txt"), "keep");
 
        var second = await RunValidatorCommandAsync(["collect", "--repo", "microsoft/aspire", "--run-id", "invalid"]);
 
        Assert.Equal(1, second.ExitCode);
        var secondRoot = ReadNamedPath(second.Output, "workspace_dir");
        Assert.NotEqual(firstRoot, secondRoot);
        Assert.Equal(bytes, await File.ReadAllBytesAsync(firstOutcome));
        Assert.Equal("keep", await File.ReadAllTextAsync(Path.Combine(firstRoot, "operator-note.txt")));
        Assert.True(File.Exists(Path.Combine(secondRoot, "recovery", "collection", "manifest.json")));
        Assert.False(Directory.Exists(Path.Combine(firstRoot, "scratch")));
        Assert.False(Directory.Exists(Path.Combine(secondRoot, "scratch")));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    [SkipOnPlatform(TestPlatforms.Windows, "Creating directory symlinks requires elevated privileges on Windows.")]
    public async Task ReplayUsesFreshOutputEvenWhenInputIsLinkedToAnEarlierReplay(bool invalid)
    {
        var saved = Directory.CreateDirectory(Path.Combine(_workspace.Path, "earlier-replay")).FullName;
        var original = invalid ? "{}" : TransientJson.Write(AnalyzeCiFailureFixtures.CreateEvidence(RunScope.PullRequest));
        await File.WriteAllTextAsync(Path.Combine(saved, "evidence.json"), original);
        var linkedInput = Path.Combine(_workspace.Path, "linked-evidence");
        Directory.CreateSymbolicLink(linkedInput, saved);
        var result = await RunValidatorCommandAsync(["replay", "--evidence-dir", linkedInput]);
 
        Assert.True(result.ExitCode == (invalid ? 1 : 0), result.Output);
        var output = ReadNamedPath(result.Output, "replay_dir");
        Assert.NotEqual(saved, output);
        Assert.Equal(original, await File.ReadAllTextAsync(Path.Combine(saved, "evidence.json")));
        Assert.Equal(!invalid, File.Exists(Path.Combine(output, "analysis-summary.md")));
    }
 
    [Fact]
    public async Task InvocationDisposesOnlyScratchAndRetainsPublishedOutputs()
    {
        var invocation = new AnalysisInvocation();
        _invocationDirectories.Add(invocation.RootDirectory);
        Directory.CreateDirectory(invocation.Evidence.RootDirectory);
        Directory.CreateDirectory(invocation.Scratch.Requests);
        await File.WriteAllTextAsync(invocation.Evidence.Summary, "retain");
        await File.WriteAllTextAsync(Path.Combine(invocation.Scratch.Requests, "request.json"), "discard");
 
        invocation.Dispose();
 
        Assert.False(Directory.Exists(invocation.Scratch.RootDirectory));
        Assert.Equal("retain", await File.ReadAllTextAsync(invocation.Evidence.Summary));
    }
 
    [Fact]
    public async Task WorkflowOutputCancellationRetainsSameInvocationsScratchDiagnostics()
    {
        using var invocation = new AnalysisInvocation();
        _invocationDirectories.Add(invocation.RootDirectory);
        var output = Path.Combine(_workspace.Path, "workflow-output");
        await invocation.WritePathsAsync("collection", output);
        Directory.CreateDirectory(invocation.Scratch.Results);
        await File.WriteAllTextAsync(Path.Combine(invocation.Scratch.Results, "failed.trx"), "current report");
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();
 
        var exitCode = await EvidencePreparation.CompleteCollectionAsync(
            invocation,
            invocation.Progress.CreateOutcome("ready"),
            output,
            cancellation.Token);
 
        Assert.Equal(1, exitCode);
        var report = Path.Combine(invocation.RecoveryDirectory("collection"), "extracted-results", "failed.trx");
        Assert.Equal("current report", await File.ReadAllTextAsync(report));
        var lines = await File.ReadAllLinesAsync(output);
        Assert.Contains("recovery_dir=" + invocation.RecoveryDirectory("collection"), lines);
        Assert.All(lines, line => Assert.False(line.StartsWith("has_work=", StringComparison.Ordinal)));
    }
 
    [Theory]
    [InlineData("evidence")]
    [InlineData("proposal")]
    public async Task PublicationRejectsHistoryCheckoutInsideItsInputs(string input)
    {
        var evidence = Directory.CreateDirectory(Path.Combine(_workspace.Path, "input-evidence")).FullName;
        var proposal = Directory.CreateDirectory(Path.Combine(_workspace.Path, "input-proposal")).FullName;
 
        var result = await RunValidatorCommandAsync(["publish", "--repo", "microsoft/aspire",
            "--evidence-dir", evidence, "--analysis-dir", proposal,
            "--history-dir", Path.Combine(input == "evidence" ? evidence : proposal, "history-checkout")]);
 
        Assert.Equal(1, result.ExitCode);
        Assert.Contains("--history-dir must be outside", result.Output, StringComparison.Ordinal);
        Assert.Empty(Directory.GetFileSystemEntries(evidence));
        Assert.Empty(Directory.GetFileSystemEntries(proposal));
    }
 
    private static string ReadNamedPath(string output, string name)
        => Assert.Single(output.Split('\n'), line => line.StartsWith(name + "=", StringComparison.Ordinal))[(name.Length + 1)..].TrimEnd('\r');
}