File: Dcp\ProcessUtilTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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.Json;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Tests.Utils;
 
namespace Aspire.Hosting.Tests.Dcp;
 
[Trait("Partition", "4")]
public class ProcessUtilTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task Run_WaitsForOutputCallbacksToFinishBeforeCompleting()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var spec = CreateSingleLineOutputProcessSpec(workspace, output =>
        {
            Assert.Equal("final-line", output);
            callbackStarted.TrySetResult();
            releaseCallback.Task.GetAwaiter().GetResult();
        });
 
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
 
        await using (processDisposable)
        {
            await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(30));
            await Task.Delay(200);
 
            Assert.False(pendingProcessResult.IsCompleted);
 
            releaseCallback.TrySetResult();
 
            var result = await pendingProcessResult.WaitAsync(TimeSpan.FromSeconds(30));
            Assert.Equal(0, result.ExitCode);
        }
    }
 
    [Fact]
    public async Task Run_IncludesCapturedOutputInNonZeroExitException()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(CreateFailingProcessSpec(workspace, ["stdout-final-line", "stderr-final-line"], emitSecondLineToStderr: true));
 
        await using (processDisposable)
        {
            var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => pendingProcessResult).WaitAsync(TimeSpan.FromSeconds(30));
 
            Assert.Contains("returned non-zero exit code 1", exception.Message);
            Assert.Contains("stdout-final-line", exception.Message);
            Assert.Contains("stderr-final-line", exception.Message);
        }
    }
 
    [Fact]
    public async Task Run_ReturnsCapturedOutputInProcessResult_WhenRetentionEnabled()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var spec = CreateFailingProcessSpec(
            workspace,
            ["stdout-final-line", "stderr-final-line"],
            emitSecondLineToStderr: true,
            throwOnNonZeroReturnCode: false,
            retainedOutputLineCount: ProcessSpec.DefaultRetainedOutputLineCount);
 
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
 
        await using (processDisposable)
        {
            var processResult = await pendingProcessResult.WaitAsync(TimeSpan.FromSeconds(30));
            var normalizedOutput = processResult.ProcessOutput.Select(static line => line.TrimEnd()).ToArray();
 
            Assert.Equal(1, processResult.ExitCode);
            Assert.Equal(2, processResult.TotalProcessOutputLineCount);
            Assert.Equal(2, processResult.ProcessOutput.Count);
            Assert.Contains("stdout-final-line", normalizedOutput);
            Assert.Contains("stderr-final-line", normalizedOutput);
        }
    }
 
    [Fact]
    public async Task Run_PassesArgumentListToProcess()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var appPath = DotnetFileAppProcess.WriteApp(workspace.WorkspaceRoot.FullName, "argument-list.cs", """
            #:sdk Microsoft.NET.Sdk
            Console.WriteLine(args[0]);
            """);
        var outputLines = new List<string>();
        var spec = DotnetFileAppProcess.CreateDcpProcessSpec(appPath, ["argument-list-value"], onOutputData: outputLines.Add);
 
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
 
        await using (processDisposable)
        {
            var result = await pendingProcessResult.WaitAsync(TimeSpan.FromSeconds(30));
 
            Assert.Equal(0, result.ExitCode);
            Assert.Contains("argument-list-value", outputLines);
        }
    }
 
    [Fact]
    public async Task Run_IncludesArgumentListInNonZeroExitException()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var appPath = DotnetFileAppProcess.WriteApp(workspace.WorkspaceRoot.FullName, "argument-list-failure.cs", """
            #:sdk Microsoft.NET.Sdk
            Environment.Exit(7);
            """);
        var spec = DotnetFileAppProcess.CreateDcpProcessSpec(
            appPath,
            ["failure-argument", "failure argument", "quote\"argument", ""],
            throwOnNonZeroReturnCode: true);
 
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
 
        await using (processDisposable)
        {
            var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => pendingProcessResult).WaitAsync(TimeSpan.FromSeconds(30));
 
            Assert.Contains("returned non-zero exit code 7", exception.Message);
            Assert.Contains("failure-argument", exception.Message);
            Assert.Contains("-- failure-argument \"failure argument\" \"quote\\\"argument\" \"\"", exception.Message);
        }
    }
 
    [Fact]
    public async Task Run_ResolvesExecutableNameFromPathBeforeStartingProcess()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var appPath = DotnetFileAppProcess.WriteApp(workspace.WorkspaceRoot.FullName, "path-executable.cs", """
            #:sdk Microsoft.NET.Sdk
            Console.WriteLine("path-executable-output");
            """);
        var dotnetPath = DotnetFileAppProcess.ResolvedExecutablePath;
        var dotnetDirectory = Path.GetDirectoryName(dotnetPath);
        var dotnetExecutableName = Path.GetFileName(dotnetPath);
        var outputLines = new List<string>();
        var spec = new ProcessSpec(dotnetExecutableName)
        {
            ArgumentList = DotnetFileAppProcess.CreateArguments(appPath),
            EnvironmentVariables = new Dictionary<string, string>
            {
                ["PATH"] = dotnetDirectory ?? string.Empty
            },
            OnOutputData = outputLines.Add,
            ThrowOnNonZeroReturnCode = false,
            ResolveExecutablePath = true
        };
 
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
 
        await using (processDisposable)
        {
            var result = await pendingProcessResult.WaitAsync(TimeSpan.FromSeconds(30));
 
            Assert.Equal(0, result.ExitCode);
            Assert.Contains("path-executable-output", outputLines);
        }
    }
 
    [Fact]
    public void Run_Throws_WhenArgumentsAndArgumentListAreSpecified()
    {
        var spec = new ProcessSpec("dotnet")
        {
            Arguments = "--version",
            ArgumentList = ["--info"],
            ThrowOnNonZeroReturnCode = false
        };
 
        var exception = Assert.Throws<ArgumentException>(() => ProcessUtil.Run(spec));
 
        Assert.StartsWith("Specify either Arguments or ArgumentList, not both.", exception.Message);
        Assert.Equal("processSpec", exception.ParamName);
    }
 
    [Fact]
    public async Task Run_TruncatesCapturedOutputToLast50LinesInNonZeroExitException()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var lines = Enumerable.Range(1, 52).Select(i => $"line-{i}").ToArray();
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(CreateFailingProcessSpec(workspace, lines));
 
        await using (processDisposable)
        {
            var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => pendingProcessResult).WaitAsync(TimeSpan.FromSeconds(30));
            var outputLines = exception.Message.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
            var normalizedOutputLines = outputLines.Select(static line => line.TrimEnd()).ToArray();
 
            Assert.Contains("Command output truncated: showing last 50 of 52 lines.", exception.Message);
            Assert.DoesNotContain("line-1", outputLines);
            Assert.DoesNotContain("line-2", outputLines);
            Assert.Contains("line-3", normalizedOutputLines);
            Assert.Contains("line-52", normalizedOutputLines);
        }
    }
 
    private static ProcessSpec CreateSingleLineOutputProcessSpec(TemporaryWorkspace workspace, Action<string> onOutputData)
    {
        var appPath = DotnetFileAppProcess.WriteApp(workspace.WorkspaceRoot.FullName, "single-line.cs", """
            #:sdk Microsoft.NET.Sdk
            Console.WriteLine("final-line");
            """);
 
        return DotnetFileAppProcess.CreateDcpProcessSpec(appPath, onOutputData: onOutputData);
    }
 
    private static ProcessSpec CreateFailingProcessSpec(
        TemporaryWorkspace workspace,
        string[] lines,
        bool emitSecondLineToStderr = false,
        bool throwOnNonZeroReturnCode = true,
        int? retainedOutputLineCount = null)
    {
        var appPath = DotnetFileAppProcess.WriteApp(workspace.WorkspaceRoot.FullName, "failing.cs", $$"""
            #:sdk Microsoft.NET.Sdk
            var lines = new[] { {{string.Join(", ", lines.Select(static line => JsonSerializer.Serialize(line)))}} };
            var emitSecondLineToStderr = {{JsonSerializer.Serialize(emitSecondLineToStderr)}};
 
            for (var i = 0; i < lines.Length; i++)
            {
                if (emitSecondLineToStderr && i == 1)
                {
                    Console.Error.WriteLine(lines[i]);
                }
                else
                {
                    Console.WriteLine(lines[i]);
                }
            }
 
            Environment.Exit(1);
            """);
 
        return DotnetFileAppProcess.CreateDcpProcessSpec(
            appPath,
            throwOnNonZeroReturnCode: throwOnNonZeroReturnCode,
            retainedOutputLineCount: retainedOutputLineCount);
    }
}