File: DownloadFailingJobLogs\DownloadFailingJobLogsToolTests.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.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using Xunit;
 
namespace Infrastructure.Tests;
 
/// <summary>
/// End-to-end tests for the DownloadFailingJobLogs script.
/// </summary>
public sealed class DownloadFailingJobLogsToolTests : IClassFixture<DownloadFailingJobLogsFixture>, IDisposable
{
    private const long RunId = 123;
    private const long JobId = 456;
    private const long ArtifactId = 789;
    private const string Repository = "microsoft/aspire";
    private const string JobName = "Tests / Linux / Sample (Sample) / Sample (linux-latest)";
    private const string ArtifactName = "logs-Sample-linux-latest";
    private const string LogContent = """
        Failed Tests.Namespace.Type.Method(input: 1) [12 ms]
        Error Message: Expected 1 but found 2.
        System.InvalidOperationException: Expected 1 but found 2.
           at Tests.Namespace.Type.Method() in TestFile.cs:line 42
        """;
 
    private readonly TemporaryWorkspace _workspace;
    private readonly DownloadFailingJobLogsFixture _fixture;
    private readonly ITestOutputHelper _output;
 
    public DownloadFailingJobLogsToolTests(DownloadFailingJobLogsFixture fixture, ITestOutputHelper output)
    {
        _fixture = fixture;
        _output = output;
        _workspace = TemporaryWorkspace.Create(output);
    }
 
    public void Dispose() => _workspace.Dispose();
 
    [Fact]
    public async Task DownloadsLogsAndExtractsArtifactsFromFixtureRun()
    {
        var fixtureDirectory = Path.Combine(_workspace.Path, "fixtures-success");
        Directory.CreateDirectory(fixtureDirectory);
 
        WriteJobsFixture(fixtureDirectory);
        WriteArtifactsFixture(fixtureDirectory, includeArtifact: true);
        WriteTextFixture(fixtureDirectory, $"repos/{Repository}/actions/jobs/{JobId}/logs", ".txt", LogContent);
        WriteArtifactFixture(fixtureDirectory);
 
        var result = await RunToolAsync(fixtureDirectory);
 
        Assert.Equal(0, result.ExitCode);
        Assert.Contains("Found 1 failed jobs", result.Output, StringComparison.Ordinal);
        Assert.Contains("Saved job logs to: failed_job_0_Tests___Linux___Sample__Sample____Sample__linux-latest_.log", result.Output, StringComparison.Ordinal);
        Assert.Contains("Downloaded artifact to: artifact_0_Sample_linux-latest.zip", result.Output, StringComparison.Ordinal);
        Assert.Contains("Found 1 .trx file(s):", result.Output, StringComparison.Ordinal);
 
        var logPath = Path.Combine(_workspace.Path, "failed_job_0_Tests___Linux___Sample__Sample____Sample__linux-latest_.log");
        Assert.True(File.Exists(logPath));
        Assert.Contains("Expected 1 but found 2.", File.ReadAllText(logPath), StringComparison.Ordinal);
 
        var extractedTrx = Path.Combine(_workspace.Path, "artifact_0_Sample_linux-latest", "testresults", "Sample_net10.0.trx");
        Assert.True(File.Exists(extractedTrx));
    }
 
    [Fact]
    public async Task ReportsMissingArtifactWithoutFailing()
    {
        var fixtureDirectory = Path.Combine(_workspace.Path, "fixtures-missing-artifact");
        Directory.CreateDirectory(fixtureDirectory);
 
        WriteJobsFixture(fixtureDirectory);
        WriteArtifactsFixture(fixtureDirectory, includeArtifact: false);
        WriteTextFixture(fixtureDirectory, $"repos/{Repository}/actions/jobs/{JobId}/logs", ".txt", LogContent);
 
        var result = await RunToolAsync(fixtureDirectory);
 
        Assert.Equal(0, result.ExitCode);
        Assert.Contains($"Artifact '{ArtifactName}' not found for this run.", result.Output, StringComparison.Ordinal);
    }
 
    private async Task<ToolResult> RunToolAsync(string fixtureDirectory)
    {
        ProcessStartInfo processStartInfo = new()
        {
            FileName = _fixture.DotNetPath,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            WorkingDirectory = _workspace.Path
        };
 
        processStartInfo.ArgumentList.Add("run");
        processStartInfo.ArgumentList.Add(_fixture.ToolPath);
        processStartInfo.ArgumentList.Add("--");
        processStartInfo.ArgumentList.Add(RunId.ToString());
        processStartInfo.Environment["ASPIRE_FAILING_TEST_ISSUE_FIXTURE_DIR"] = fixtureDirectory;
 
        using var process = new Process { StartInfo = processStartInfo };
        process.Start();
 
        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();
 
        using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(2));
        try
        {
            await process.WaitForExitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            process.Kill(entireProcessTree: true);
            throw;
        }
 
        var stdout = await stdoutTask.ConfigureAwait(false);
        var stderr = await stderrTask.ConfigureAwait(false);
 
        if (!string.IsNullOrWhiteSpace(stdout))
        {
            _output.WriteLine(stdout);
        }
 
        if (!string.IsNullOrWhiteSpace(stderr))
        {
            _output.WriteLine(stderr);
        }
 
        return new ToolResult(process.ExitCode, stdout, stderr);
    }
 
    private static void WriteJobsFixture(string fixtureDirectory)
    {
        WriteJsonFixture(
            fixtureDirectory,
            $"repos/{Repository}/actions/runs/{RunId}/jobs?per_page=100&page=1",
            new
            {
                jobs = new[]
                {
                    new
                    {
                        id = JobId,
                        name = JobName,
                        html_url = $"https://github.com/{Repository}/actions/runs/{RunId}/job/{JobId}",
                        conclusion = "failure",
                        status = "completed"
                    }
                }
            });
    }
 
    private static void WriteArtifactsFixture(string fixtureDirectory, bool includeArtifact)
    {
        WriteJsonFixture(
            fixtureDirectory,
            $"repos/{Repository}/actions/runs/{RunId}/artifacts?per_page=100&page=1",
            new
            {
                artifacts = includeArtifact
                    ? new[]
                    {
                        new
                        {
                            id = ArtifactId,
                            name = ArtifactName
                        }
                    }
                    : Array.Empty<object>()
            });
    }
 
    private static void WriteArtifactFixture(string fixtureDirectory)
    {
        var zipPath = GetFixturePath(fixtureDirectory, $"repos/{Repository}/actions/artifacts/{ArtifactId}/zip", ".zip");
        TestTrxBuilder.CreateArtifactZip(
            zipPath,
            "testresults/Sample_net10.0.trx",
            new TestTrxCase(
                CanonicalTestName: "Tests.Namespace.Type.Method",
                DisplayName: "Tests.Namespace.Type.Method(input: 1)",
                Outcome: "Failed",
                ErrorMessage: "Expected 1 but found 2.",
                StackTrace: "at Tests.Namespace.Type.Method() in TestFile.cs:line 42",
                StdOut: "stdout line 1"));
    }
 
    private static void WriteJsonFixture<T>(string fixtureDirectory, string endpoint, T payload)
        => WriteTextFixture(fixtureDirectory, endpoint, ".json", JsonSerializer.Serialize(payload));
 
    private static void WriteTextFixture(string fixtureDirectory, string endpoint, string extension, string content)
    {
        var path = GetFixturePath(fixtureDirectory, endpoint, extension);
        File.WriteAllText(path, content);
    }
 
    private static string GetFixturePath(string fixtureDirectory, string endpoint, string extension)
    {
        var fileName = Regex.Replace(endpoint, @"[^A-Za-z0-9._-]+", "_").Trim('_');
        return Path.Combine(fixtureDirectory, $"{fileName}{extension}");
    }
 
    private sealed record ToolResult(int ExitCode, string Output, string Error);
}