File: Commands\Test\MTP\ArtifactPostProcessingManager.cs
Web Access
Project: src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// 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.Globalization;
using System.Text.Json;
using Microsoft.DotNet.Cli.Commands.Test.IPC.Models;
using Microsoft.DotNet.Cli.Commands.Test.Terminal;

namespace Microsoft.DotNet.Cli.Commands.Test;

internal sealed class ArtifactPostProcessingManager
{
    private readonly Lock _lock = new();
    private readonly Dictionary<TestModule, ApplicationState> _applications = [];
    private readonly List<ArtifactPostProcessingArtifact> _artifacts = [];

    public void RecordCapabilities(
        TestModule module,
        string? targetFramework,
        string? architecture,
        HandshakeMessage handshakeMessage)
    {
        string[] kinds = ParseCapabilities(handshakeMessage, HandshakeMessagePropertyNames.SupportedPostProcessorKinds);
        string[] extensions = ParseCapabilities(handshakeMessage, HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy)
            .Select(extension => extension.ToLowerInvariant())
            .ToArray();

        if (kinds.Length == 0 && extensions.Length == 0)
        {
            return;
        }

        lock (_lock)
        {
            if (!_applications.TryGetValue(module, out ApplicationState? application))
            {
                application = new ApplicationState(module, targetFramework, architecture);
                _applications.Add(module, application);
            }

            application.SupportedKinds.UnionWith(kinds);
            application.SupportedExtensions.UnionWith(extensions);
        }
    }

    public void RecordArtifact(
        TestModule module,
        string? targetFramework,
        string? architecture,
        string executionId,
        FileArtifactMessage artifact)
    {
        lock (_lock)
        {
            _artifacts.Add(new ArtifactPostProcessingArtifact(
                artifact.FullPath!,
                artifact.Kind,
                module.TargetPath,
                targetFramework,
                architecture,
                executionId));
        }
    }

    public async Task ExecuteAsync(
        BuildOptions buildOptions,
        TerminalTestReporter output,
        CtrlCCancellationManager ctrlC)
    {
        try
        {
            await ExecuteCoreAsync(buildOptions, output, ctrlC);
        }
        catch (Exception ex)
        {
            // Individual jobs already degrade their own failures to warnings. This guard covers
            // everything around them — planning, the progress line, telemetry — so that no part of a
            // best-effort convenience layered on a finished run can escape and turn that run into a
            // CLI crash with a different exit code.
            Logger.LogTrace($"Artifact post-processing failed: {ex}");
        }
    }

    private async Task ExecuteCoreAsync(
        BuildOptions buildOptions,
        TerminalTestReporter output,
        CtrlCCancellationManager ctrlC)
    {
        ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan(
            SnapshotApplications(),
            SnapshotArtifacts());
        ArtifactPostProcessingJob[] runnableJobs =
        [
            .. plan.Jobs.Where(job =>
            {
                bool supported = !TestApplication.RequiresHttpTransport(job.Application.Module);
                if (!supported)
                {
                    Logger.LogTrace(
                        $"Skipping artifact post-processing for WebAssembly module '{job.Application.Module.TargetPath}' because no browser-aware merge host is available.");
                }

                return supported;
            }),
        ];

        if (runnableJobs.Length == 0)
        {
            return;
        }

        // Post-processing runs after the last test application has exited and before the run summary
        // is rendered, so without this line a merge that takes a while is indistinguishable from a
        // hung CLI: the progress area is empty and no assembly is still running.
        output.WriteInformationMessage(CliCommandStrings.ArtifactPostProcessingStarted);

        long startTimestamp = Stopwatch.GetTimestamp();
        int executedJobs = 0;
        int failedJobs = 0;

        foreach (ArtifactPostProcessingJob job in runnableJobs)
        {
            if (ctrlC.Token.IsCancellationRequested)
            {
                break;
            }

            string tempDirectory = Path.Combine(
                Path.GetTempPath(),
                $"dotnet-test-postproc-{Guid.NewGuid():N}");

            executedJobs++;

            try
            {
                Directory.CreateDirectory(tempDirectory);
                string manifestPath = Path.Combine(tempDirectory, "manifest.json");
                string outputDirectory = GetOutputDirectory(buildOptions, job);
                Directory.CreateDirectory(outputDirectory);
                WriteManifest(manifestPath, outputDirectory, job.Groups.SelectMany(group => group.Artifacts));

                var invocation = new ArtifactPostProcessingInvocation(manifestPath);
                var toolOptions = new TestOptions(
                    IsHelp: false,
                    IsDiscovery: false,
                    ListTestsFormat: TestListFormat.Text,
                    IsArtifactPostProcessing: true);

                using var application = new TestApplication(
                    job.Application.Module,
                    buildOptions,
                    toolOptions,
                    // Post-processing merges artifacts across modules, so it keeps writing to the
                    // shared results directory even when the run uses a per-module layout.
                    TestResultsDirectoryResolver.CreateShared(buildOptions.PathOptions, Directory.GetCurrentDirectory()),
                    output,
                    onHelpRequested: _ => { },
                    artifactPostProcessingManager: this,
                    artifactPostProcessingInvocation: invocation);

                int exitCode = await application.RunAsync(ctrlC);
                ApplyOutputs(output, job, invocation.SnapshotOutputs());

                if (invocation.FailureMessage is { } failureMessage)
                {
                    failedJobs += ReportFailureUnlessCancelled(output, ctrlC, string.Format(
                        CultureInfo.CurrentCulture,
                        CliCommandStrings.ArtifactPostProcessingFailed,
                        job.Application.Module.TargetPath,
                        failureMessage)) ? 1 : 0;
                }
                else if (exitCode != ExitCode.Success)
                {
                    failedJobs += ReportFailureUnlessCancelled(output, ctrlC, string.Format(
                        CultureInfo.CurrentCulture,
                        CliCommandStrings.ArtifactPostProcessingProcessFailed,
                        job.Application.Module.TargetPath,
                        exitCode)) ? 1 : 0;
                }
            }
            catch (Exception ex)
            {
                // Post-processing is a best-effort convenience on top of a completed test run: the
                // original artifacts are always still on disk and still reported, so no failure here
                // may escape and turn a finished run into a CLI crash with a different exit code.
                Logger.LogTrace($"Artifact post-processing with '{job.Application.Module.TargetPath}' failed: {ex}");

                failedJobs += ReportFailureUnlessCancelled(output, ctrlC, string.Format(
                    CultureInfo.CurrentCulture,
                    CliCommandStrings.ArtifactPostProcessingFailed,
                    job.Application.Module.TargetPath,
                    ex.Message)) ? 1 : 0;
            }
            finally
            {
                try
                {
                    if (Directory.Exists(tempDirectory))
                    {
                        Directory.Delete(tempDirectory, recursive: true);
                    }
                }
                catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
                {
                    Logger.LogTrace($"Failed to clean artifact post-processing temporary directory '{tempDirectory}': {ex}");
                }
            }
        }

        ArtifactPostProcessingTelemetry.TrackPostProcessing(
            plan,
            executedJobs,
            failedJobs,
            Stopwatch.GetElapsedTime(startTimestamp));
    }

    /// <summary>
    /// Reports a post-processing failure, unless the user cancelled the run, and returns whether it
    /// was reported. Cancellation kills the post-processing process the same way it kills a test
    /// application, so the resulting failure is the cancellation the user asked for rather than a
    /// post-processing problem worth reporting — or counting as a failure in telemetry.
    /// </summary>
    internal static bool ReportFailureUnlessCancelled(
        TerminalTestReporter output,
        CtrlCCancellationManager ctrlC,
        string message)
    {
        if (ctrlC.Token.IsCancellationRequested)
        {
            return false;
        }

        output.WriteWarningMessage(message);
        return true;
    }

    internal IReadOnlyList<ArtifactPostProcessingApplication> SnapshotApplications()
    {
        lock (_lock)
        {
            return
            [
                .. _applications.Values.Select(application => new ArtifactPostProcessingApplication(
                    application.Module,
                    application.TargetFramework,
                    application.Architecture,
                    new HashSet<string>(application.SupportedKinds, StringComparer.Ordinal),
                    new HashSet<string>(application.SupportedExtensions, StringComparer.Ordinal)))
            ];
        }
    }

    internal IReadOnlyList<ArtifactPostProcessingArtifact> SnapshotArtifacts()
    {
        lock (_lock)
        {
            return [.. _artifacts];
        }
    }

    private static string[] ParseCapabilities(HandshakeMessage handshakeMessage, byte propertyName)
        => !handshakeMessage.Properties.TryGetValue(propertyName, out string? capabilities)
            ? []
            : capabilities
                .Split(CliConstants.SemiColon, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                .Where(capability => capability.Length > 0)
                .Distinct(StringComparer.Ordinal)
                .ToArray();

    internal static string GetOutputDirectory(BuildOptions buildOptions, ArtifactPostProcessingJob job)
    {
        if (buildOptions.PathOptions.ResultsDirectoryPath is { } resultsDirectory)
        {
            return Path.GetFullPath(resultsDirectory);
        }

        if (job.Application.Module.UseArtifactsOutput
            && TestResultsDirectoryResolver.GetResultsDirectoryRoot(
                buildOptions.PathOptions,
                job.Application.Module,
                Directory.GetCurrentDirectory()) is { } artifactsResultsDirectory)
        {
            return Path.GetFullPath(artifactsResultsDirectory);
        }

        ArtifactPostProcessingArtifact[] inputs =
        [
            .. job.Groups
                .SelectMany(group => group.Artifacts)
                .OrderBy(artifact => artifact.Path, FileUtilities.PathComparer)
        ];

        // Without --results-directory every test application writes its artifacts next to its own
        // binaries, so no directory belongs to the run as a whole. Prefer one the elected application
        // already writes to: the merged artifact then lands beside the reports it summarizes instead
        // of inside an unrelated project's output. An application can be elected purely for the kinds
        // it advertises without having produced any of the inputs, so fall back to the first input
        // directory in path order.
        ArtifactPostProcessingArtifact preferredInput = inputs.FirstOrDefault(artifact =>
            FileUtilities.PathComparer.Equals(artifact.ProducingTestModule, job.Application.Module.TargetPath))
            ?? inputs[0];

        return Path.GetDirectoryName(Path.GetFullPath(preferredInput.Path))!;
    }

    private static void WriteManifest(
        string manifestPath,
        string outputDirectory,
        IEnumerable<ArtifactPostProcessingArtifact> artifacts)
    {
        using FileStream stream = File.Create(manifestPath);
        using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });

        writer.WriteStartObject();
        writer.WriteNumber("schemaVersion", 1);
        writer.WriteString("outputDirectory", outputDirectory);
        writer.WriteStartArray("inputs");
        foreach (ArtifactPostProcessingArtifact artifact in artifacts
            .OrderBy(artifact => artifact.Path, FileUtilities.PathComparer))
        {
            writer.WriteStartObject();
            writer.WriteString("path", artifact.Path);
            WriteNullableString(writer, "kind", artifact.Kind);
            WriteNullableString(writer, "producingTestModule", artifact.ProducingTestModule);
            WriteNullableString(writer, "targetFramework", artifact.TargetFramework);
            WriteNullableString(writer, "architecture", artifact.Architecture);
            writer.WriteString("executionId", artifact.ExecutionId);
            writer.WriteEndObject();
        }

        writer.WriteEndArray();
        writer.WriteEndObject();
    }

    private static void WriteNullableString(Utf8JsonWriter writer, string propertyName, string? value)
    {
        if (value is null)
        {
            writer.WriteNull(propertyName);
        }
        else
        {
            writer.WriteString(propertyName, value);
        }
    }

    internal static void ApplyOutputs(
        TerminalTestReporter output,
        ArtifactPostProcessingJob job,
        IReadOnlyList<ArtifactPostProcessingArtifact> processedArtifacts)
    {
        foreach (ArtifactPostProcessingArtifact processedArtifact in processedArtifacts)
        {
            string outputExtension = Path.GetExtension(processedArtifact.Path).ToLowerInvariant();
            // The dispatcher gives one processor both its kind-tagged inputs and matching legacy
            // extension inputs, so the returned output consumes both groups.
            ArtifactPostProcessingGroup[] consumedGroups =
            [
                .. job.Groups.Where(group =>
                    group.IsKind
                        ? string.Equals(group.Key, processedArtifact.Kind, StringComparison.Ordinal)
                        : string.Equals(group.Key, outputExtension, StringComparison.Ordinal))
            ];

            if (consumedGroups.Length > 0)
            {
                var consumedPaths = new HashSet<string>(
                    consumedGroups.SelectMany(group => group.Artifacts).Select(artifact => artifact.Path),
                    FileUtilities.PathComparer);
                output.RemoveArtifacts(consumedPaths);
            }

            output.ArtifactAdded(
                outOfProcess: true,
                job.Application.Module.TargetPath,
                job.Application.TargetFramework,
                job.Application.Architecture,
                processedArtifact.ExecutionId,
                testName: null,
                processedArtifact.Path);
        }
    }

    private sealed class ApplicationState(TestModule module, string? targetFramework, string? architecture)
    {
        public TestModule Module { get; } = module;
        public string? TargetFramework { get; } = targetFramework;
        public string? Architecture { get; } = architecture;
        public HashSet<string> SupportedKinds { get; } = new(StringComparer.Ordinal);
        public HashSet<string> SupportedExtensions { get; } = new(StringComparer.Ordinal);
    }
}

internal sealed class ArtifactPostProcessingInvocation(string manifestPath)
{
    private readonly Lock _lock = new();
    private readonly List<ArtifactPostProcessingArtifact> _outputs = [];
    private string? _failureMessage;

    public string ManifestPath { get; } = manifestPath;

    public string? FailureMessage
    {
        get
        {
            lock (_lock)
            {
                return _failureMessage;
            }
        }
    }

    public void RecordFailure(string message)
    {
        lock (_lock)
        {
            _failureMessage ??= message;
        }
    }

    public void RecordOutput(
        TestModule module,
        string? targetFramework,
        string? architecture,
        string executionId,
        FileArtifactMessage artifact)
    {
        lock (_lock)
        {
            _outputs.Add(new ArtifactPostProcessingArtifact(
                artifact.FullPath!,
                artifact.Kind,
                module.TargetPath,
                targetFramework,
                architecture,
                executionId));
        }
    }

    public IReadOnlyList<ArtifactPostProcessingArtifact> SnapshotOutputs()
    {
        lock (_lock)
        {
            return [.. _outputs];
        }
    }
}