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.ComponentModel;
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)
    {
        ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan(
            SnapshotApplications(),
            SnapshotArtifacts());

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

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

            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,
                    output,
                    onHelpRequested: _ => { },
                    artifactPostProcessingManager: this,
                    artifactPostProcessingInvocation: invocation);

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

                if (invocation.FailureMessage is { } failureMessage)
                {
                    output.WriteWarningMessage(string.Format(
                        CultureInfo.CurrentCulture,
                        CliCommandStrings.ArtifactPostProcessingFailed,
                        job.Application.Module.TargetPath,
                        failureMessage));
                }
                else if (exitCode != ExitCode.Success)
                {
                    output.WriteWarningMessage(string.Format(
                        CultureInfo.CurrentCulture,
                        CliCommandStrings.ArtifactPostProcessingProcessFailed,
                        job.Application.Module.TargetPath,
                        exitCode));
                }
            }
            catch (Exception ex) when (ex is IOException
                or UnauthorizedAccessException
                or InvalidOperationException
                or Win32Exception
                or NotSupportedException
                or TimeoutException)
            {
                output.WriteWarningMessage(string.Format(
                    CultureInfo.CurrentCulture,
                    CliCommandStrings.ArtifactPostProcessingFailed,
                    job.Application.Module.TargetPath,
                    ex.Message));
            }
            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}");
                }
            }
        }
    }

    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();

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

        string firstInputPath = job.Groups
            .SelectMany(group => group.Artifacts)
            .Select(artifact => artifact.Path)
            .OrderBy(path => path, FileUtilities.PathComparer)
            .First();

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

    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];
        }
    }
}