File: Utils\TemporaryWorkspaceTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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.ComponentModel;
using System.Diagnostics;
using Aspire.Cli.Tests.TestServices;
 
namespace Aspire.Cli.Tests.Utils;
 
public class TemporaryWorkspaceTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task InitializeGitAsync_LogsProcessDetailsAndCreatesRepository()
    {
        await GitTestHelper.EnsureGitAvailableAsync(outputHelper);
        var recordingOutput = new RecordingTestOutputHelper(outputHelper);
        using var workspace = TemporaryWorkspace.CreateForCli(recordingOutput);
 
        Assert.Same(recordingOutput, workspace.TestOutputHelper);
 
        await workspace.InitializeGitAsync();
 
        Assert.True(Directory.Exists(Path.Combine(workspace.Path, ".git")));
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith($"Starting 'git init' in '{workspace.Path}'", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.Contains("'git init' started with PID ", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.Contains(" stdout: ", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith("stdout closed", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith("stderr closed", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.Contains("finished after ", StringComparison.Ordinal) && message.EndsWith("exit code: 0", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task RunGitAsync_LargeOutput_DrainsBothStreams()
    {
        await GitTestHelper.EnsureGitAvailableAsync(outputHelper);
        var recordingOutput = new RecordingTestOutputHelper(outputHelper);
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var stdoutLine = new string('o', 1024);
        var stderrLine = new string('e', 1024);
        const int lineCount = 128;
 
        // Git's ! aliases run through its shell on every platform, including Git for Windows.
        // Each pipe receives over 128 KiB, enough to block a runner that waits before reading.
        var alias = $"!i=0; while [ \"$i\" -lt {lineCount} ]; do printf '%s\\n' '{stdoutLine}'; printf '%s\\n' '{stderrLine}' >&2; i=$((i+1)); done";
 
        await TemporaryWorkspaceGitExtensions.RunGitAsync(workspace.Path, recordingOutput, ["-c", $"alias.noisy={alias}", "noisy"], TestContext.Current.CancellationToken);
 
        Assert.Equal(lineCount, recordingOutput.Messages.Count(message => message.EndsWith($"stdout: {stdoutLine}", StringComparison.Ordinal)));
        Assert.Equal(lineCount, recordingOutput.Messages.Count(message => message.EndsWith($"stderr: {stderrLine}", StringComparison.Ordinal)));
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith("exit code: 0", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task RunGitAsync_NonZeroExit_ReportsOutputAndWorkingDirectory()
    {
        await GitTestHelper.EnsureGitAvailableAsync(outputHelper);
        var recordingOutput = new RecordingTestOutputHelper(outputHelper);
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string alias = "!echo test-stdout; echo test-stderr >&2; exit 7";
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            TemporaryWorkspaceGitExtensions.RunGitAsync(workspace.Path, recordingOutput, ["-c", $"alias.fail={alias}", "fail"], TestContext.Current.CancellationToken));
 
        Assert.Equal($"'git -c alias.fail={alias} fail' in '{workspace.Path}' failed with exit code 7. stdout: test-stdout{Environment.NewLine}, stderr: test-stderr{Environment.NewLine}", exception.Message);
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith("stdout: test-stdout", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith("stderr: test-stderr", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith("exit code: 7", StringComparison.Ordinal));
    }
 
    [Theory]
    [InlineData("!echo ready; sleep 60")]
    [InlineData("!echo ready")]
    public async Task RunGitAsync_Cancellation_StopsProcessAndReportsPartialOutput(string alias)
    {
        await GitTestHelper.EnsureGitAvailableAsync(outputHelper);
        var recordingOutput = new RecordingTestOutputHelper(outputHelper);
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var cancellation = new CancellationTokenSource();
        recordingOutput.MessageWritten += message =>
        {
            // Cancel only after the shell is running, without relying on a timing delay.
            if (message.EndsWith("stdout: ready", StringComparison.Ordinal))
            {
                cancellation.Cancel();
            }
        };
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            TemporaryWorkspaceGitExtensions.RunGitAsync(workspace.Path, recordingOutput, ["-c", $"alias.wait={alias}", "wait"], cancellation.Token));
 
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith("stdout: ready", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.Contains("was canceled", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.Contains("finished after ", StringComparison.Ordinal) && System.Text.RegularExpressions.Regex.IsMatch(message, @"exit code: -?\d+$"));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task RunGitAsync_PostStartWin32Failure_Propagates(bool checkAvailability)
    {
        var recordingOutput = new RecordingTestOutputHelper(outputHelper);
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var expectedException = new Win32Exception(5, "Simulated post-start failure.");
        recordingOutput.MessageWritten += message =>
        {
            if (message.Contains(" stdout: ", StringComparison.Ordinal))
            {
                throw expectedException;
            }
        };
 
        var exception = await Assert.ThrowsAsync<Win32Exception>(() => checkAvailability
            ? GitTestHelper.EnsureGitAvailableAsync(recordingOutput)
            : GitTestHelper.RunGitAsync(workspace.Path, recordingOutput, "--version"));
 
        Assert.Same(expectedException, exception);
    }
 
    [Theory]
    [InlineData("started")]
    [InlineData("stdout")]
    [InlineData("stderr")]
    public async Task RunGitAsync_PostStartFailure_StopsProcessAndPreservesException(string failurePoint)
    {
        await GitTestHelper.EnsureGitAvailableAsync(outputHelper);
        var recordingOutput = new RecordingTestOutputHelper(outputHelper);
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var expectedException = new Win32Exception(5, "Simulated post-start failure.");
        Process? process = null;
        recordingOutput.MessageWritten += message =>
        {
            // Startup diagnostics contain "started with PID <pid> after <elapsed>".
            // Capture the live process so exit is checked independently of the final log.
            var match = System.Text.RegularExpressions.Regex.Match(message, @"started with PID (\d+) after ");
            if (match.Success)
            {
                process = Process.GetProcessById(int.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture));
            }
 
            if ((failurePoint == "started" && match.Success) || message.EndsWith($"{failurePoint}: ready", StringComparison.Ordinal))
            {
                throw expectedException;
            }
        };
 
        try
        {
            var exception = await Assert.ThrowsAsync<Win32Exception>(() =>
                TemporaryWorkspaceGitExtensions.RunGitAsync(workspace.Path, recordingOutput, ["-c", "alias.wait=!echo ready; echo ready >&2; sleep 60", "wait"], TestContext.Current.CancellationToken));
 
            Assert.Same(expectedException, exception);
            Assert.NotNull(process);
            Assert.True(process.HasExited);
            Assert.Contains(recordingOutput.Messages, message => message.Contains("failed after ", StringComparison.Ordinal) && message.Contains(expectedException.Message, StringComparison.Ordinal));
            Assert.Contains(recordingOutput.Messages, message => message.Contains("finished after ", StringComparison.Ordinal) && System.Text.RegularExpressions.Regex.IsMatch(message, @"exit code: -?\d+$"));
        }
        finally
        {
            if (process is not null)
            {
                // Reap the alias even when a regression leaves it running.
                using (process)
                {
                    if (!process.HasExited)
                    {
                        process.Kill(entireProcessTree: true);
                    }
 
                    await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
                }
            }
        }
    }
 
    [Fact]
    public async Task RunGitAsync_Timeout_StopsProcessAndReportsPartialOutput()
    {
        await GitTestHelper.EnsureGitAvailableAsync(outputHelper);
        var recordingOutput = new RecordingTestOutputHelper(outputHelper);
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var stopwatch = Stopwatch.StartNew();
 
        var exception = await Assert.ThrowsAsync<TimeoutException>(() =>
            TemporaryWorkspaceGitExtensions.RunGitAsync(workspace.Path, recordingOutput, ["-c", "alias.wait=!echo ready; sleep 60", "wait"], TestContext.Current.CancellationToken));
 
        Assert.True(stopwatch.Elapsed >= TimeSpan.FromSeconds(30));
        Assert.Matches(@"^'git -c alias.wait=!echo ready; sleep 60 wait' in '.*' \(PID \d+\) timed out after 30 seconds\.$", exception.Message);
        Assert.Contains(recordingOutput.Messages, message => message.EndsWith("stdout: ready", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.Contains("timed out after ", StringComparison.Ordinal));
        Assert.Contains(recordingOutput.Messages, message => message.Contains("finished after ", StringComparison.Ordinal) && System.Text.RegularExpressions.Regex.IsMatch(message, @"exit code: -?\d+$"));
    }
 
    [Fact]
    public void Create_PreservesWorkspaceWhenFailureCaptureRequested()
    {
        const string preserveWorkspaceOnFailureKey = "PreserveWorkspaceOnFailure";
        var keyValueStorage = TestContext.Current.KeyValueStorage;
        keyValueStorage[preserveWorkspaceOnFailureKey] = true;
        var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var workspacePath = workspace.WorkspaceRoot.FullName;
 
        try
        {
            workspace.Dispose();
 
            Assert.True(Directory.Exists(workspacePath));
 
            TemporaryWorkspace.ReleasePreservation(workspacePath);
 
            Assert.False(Directory.Exists(workspacePath));
        }
        finally
        {
            keyValueStorage.TryRemove(preserveWorkspaceOnFailureKey, out _);
            TemporaryWorkspace.ReleasePreservation(workspacePath);
        }
 
        var disposableWorkspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var disposableWorkspacePath = disposableWorkspace.WorkspaceRoot.FullName;
 
        disposableWorkspace.Dispose();
 
        Assert.False(Directory.Exists(disposableWorkspacePath));
    }
 
    [Fact]
    public void ReleasePreservation_DeletesPreservedWorkspaceWhenRequested()
    {
        var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var workspacePath = workspace.WorkspaceRoot.FullName;
 
        workspace.Preserve();
        workspace.Dispose();
 
        Assert.True(Directory.Exists(workspacePath));
 
        TemporaryWorkspace.ReleasePreservation(workspacePath);
 
        Assert.False(Directory.Exists(workspacePath));
    }
 
    [Fact]
    public void ReleasePreservation_LeavesPreservedWorkspaceWhenDeletionDisabled()
    {
        var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var workspacePath = workspace.WorkspaceRoot.FullName;
 
        workspace.Preserve();
        workspace.Dispose();
 
        try
        {
            TemporaryWorkspace.ReleasePreservation(workspacePath, deleteDirectory: false);
 
            Assert.True(Directory.Exists(workspacePath));
        }
        finally
        {
            if (Directory.Exists(workspacePath))
            {
                Directory.Delete(workspacePath, recursive: true);
            }
        }
    }
}