File: Pipelines\PipelineOutputResolutionTests.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 Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
 
#pragma warning disable ASPIREPIPELINES005 // Tests opt into the experimental output diffing API.
 
namespace Aspire.Hosting.Tests.Pipelines;
 
public class PipelineOutputResolutionTests(ITestOutputHelper output)
{
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task EachCaptureResolvesItsProviderAndComparisonUsesBothSnapshots(bool singleton, bool sequential)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "artifact.txt");
        await File.WriteAllTextAsync(artifact, "before", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish,
            outputPath: workspace.Path, step: "publish-and-diff");
        var snapshotters = new List<ContentSnapshotter>();
        var comparer = new ContentComparer();
        ContentSnapshotter Create(IServiceProvider services)
        {
            var snapshotter = new ContentSnapshotter();
            snapshotters.Add(snapshotter);
            return snapshotter;
        }
        if (singleton)
        {
            builder.Services.AddSingleton<IPipelineOutputSnapshotter<ContentSnapshot>>(Create);
        }
        else
        {
            builder.Services.AddTransient<IPipelineOutputSnapshotter<ContentSnapshot>>(Create);
        }
        builder.Services.AddSingleton<IPipelineOutputComparer<ContentSnapshot>>(comparer);
        builder.Services.AddSingleton<PipelineOutputSnapshotter>(_ => throw new InvalidOperationException("Default capture must not resolve."));
        builder.WithOutputDiffing<ContentSnapshot, ContentSnapshotter, ContentComparer>();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, _ =>
            File.WriteAllTextAsync(artifact, "after", TestContext.Current.CancellationToken));
        using var app = builder.Build();
        var context = NewContext(app);
        var pipeline = Assert.IsType<DistributedApplicationPipeline>(app.Services.GetRequiredService<IDistributedApplicationPipeline>());
        Assert.Empty(snapshotters);
        Assert.Empty(comparer.Comparisons);
        Assert.Equal("before", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
 
        var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => sequential
            ? pipeline.ExecuteStepSequentiallyAsync("publish-and-diff", context)
            : pipeline.ExecuteAsync(context));
 
        Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: artifact.txt", failure.Message);
        var comparison = Assert.Single(comparer.Comparisons);
        Assert.Equal(new(workspace.Path, "before"), comparison.Before);
        Assert.Equal(new(workspace.Path, "after"), comparison.After);
        Assert.Equal("after", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        Assert.Equal(singleton ? 1 : 2, snapshotters.Count);
        Assert.All(snapshotters, snapshotter => Assert.Equal(singleton ? 2 : 1, snapshotter.Captures.Count));
        Assert.All(snapshotters, snapshotter => Assert.False(snapshotter.Disposed));
        app.Dispose();
        Assert.All(snapshotters, snapshotter => Assert.True(snapshotter.Disposed));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task DefaultValueSnapshotIsACapturedBaseline(bool sequential)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish,
            outputPath: workspace.Path, step: "publish-and-diff");
        var snapshotter = new ValueSnapshotter();
        var comparer = new ValueComparer();
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<int>>(snapshotter);
        builder.Services.AddSingleton<IPipelineOutputComparer<int>>(comparer);
        builder.WithOutputDiffing<int, ValueSnapshotter, ValueComparer>();
        using var app = builder.Build();
        var pipeline = Assert.IsType<DistributedApplicationPipeline>(app.Services.GetRequiredService<IDistributedApplicationPipeline>());
        var context = NewContext(app);
        Assert.Equal(0, snapshotter.Captures);
        Assert.Equal(0, comparer.Comparisons);
 
        await Run();
        Assert.Equal(2, snapshotter.Captures);
        Assert.Equal(1, comparer.Comparisons);
        await Run();
        Assert.Equal(4, snapshotter.Captures);
        Assert.Equal(2, comparer.Comparisons);
 
        Task Run() => sequential
            ? pipeline.ExecuteStepSequentiallyAsync("publish-and-diff", context)
            : pipeline.ExecuteAsync(context);
    }
 
    [Theory]
    [InlineData(false, PipelineOutputDiffMode.Auto)]
    [InlineData(true, PipelineOutputDiffMode.Auto)]
    [InlineData(false, PipelineOutputDiffMode.FileSystem)]
    [InlineData(true, PipelineOutputDiffMode.FileSystem)]
    [InlineData(true, PipelineOutputDiffMode.Git)]
    public async Task WorktreeAvailabilityChangesBetweenCaptures(bool initiallyGit, PipelineOutputDiffMode mode)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var root = workspace.CreateDirectory("output").FullName;
        var artifact = Path.Combine(root, "artifact.txt");
        await File.WriteAllTextAsync(artifact, "unchanged\n", TestContext.Current.CancellationToken);
        if (initiallyGit)
        {
            await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "init");
        }
        var administration = Path.Combine(workspace.Path, ".git");
        Assert.Equal(initiallyGit, Directory.Exists(administration));
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish,
            outputPath: root, step: "publish-and-diff");
        builder.WithOutputDiffing();
        builder.Services.Configure<PipelineOutputDiffOptions>(options => options.Mode = mode);
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, async _ =>
        {
            Assert.Equal(initiallyGit, Directory.Exists(administration));
            if (initiallyGit)
            {
                Directory.Move(administration, Path.Combine(workspace.Path, "saved-git"));
            }
            else
            {
                await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "init");
            }
            Assert.Equal(!initiallyGit, Directory.Exists(administration));
        });
        using var app = builder.Build();
 
        if (mode == PipelineOutputDiffMode.FileSystem)
        {
            await ExecuteAsync(app);
        }
        else
        {
            var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
            if (mode == PipelineOutputDiffMode.Auto)
            {
                // Git object IDs and raw-byte hashes differ even though the file does not.
                Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: artifact.txt", failure.Message);
            }
            else
            {
                Assert.Contains("Git is required", failure.Message);
            }
        }
        Assert.Equal("unchanged\n", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        Assert.Equal(!initiallyGit, Directory.Exists(administration));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task CaptureErrorsAndCancellationDoNotFallBack(bool cancel)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var runner = new PipelineOutputTestProcessRunner(args =>
        {
            if (args.Contains("rev-parse"))
            {
                return new(0, "true\n\n", "");
            }
            if (cancel)
            {
                cancellation.Cancel();
                cancellation.Token.ThrowIfCancellationRequested();
            }
            return new(1, "", "capture-failure");
        });
        var snapshotter = new DefaultPipelineOutputSnapshotter(new(), runner, Options.Create(new PipelineOutputDiffOptions()));
        Assert.False(cancellation.IsCancellationRequested);
        Assert.Empty(runner.Calls);
 
        if (cancel)
        {
            var failure = await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
                snapshotter.CaptureAsync(workspace.Path, cancellation.Token));
            Assert.Equal(cancellation.Token, failure.CancellationToken);
        }
        else
        {
            var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() =>
                snapshotter.CaptureAsync(workspace.Path, cancellation.Token));
            Assert.Contains("capture-failure", failure.Message);
        }
        Assert.Equal(2, runner.Calls.Count);
    }
 
    private static PipelineContext NewContext(DistributedApplication app) => new(
        app.Services.GetRequiredService<DistributedApplicationModel>(),
        app.Services.GetRequiredService<DistributedApplicationExecutionContext>(), app.Services,
        NullLogger.Instance, TestContext.Current.CancellationToken);
 
    private static Task ExecuteAsync(DistributedApplication app) =>
        app.Services.GetRequiredService<IDistributedApplicationPipeline>().ExecuteAsync(NewContext(app));
 
    private sealed class ValueSnapshotter : IPipelineOutputSnapshotter<int>
    {
        public int Captures { get; private set; }
 
        public Task<int> CaptureAsync(string outputDirectory, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Captures++;
            return Task.FromResult(0);
        }
    }
 
    private sealed class ValueComparer : IPipelineOutputComparer<int>
    {
        public int Comparisons { get; private set; }
 
        public Task<IReadOnlyList<PipelineOutputChange>> CompareAsync(int before, int after, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Assert.Equal(0, before);
            Assert.Equal(0, after);
            Comparisons++;
            return Task.FromResult<IReadOnlyList<PipelineOutputChange>>([]);
        }
    }
 
    private readonly record struct ContentSnapshot(string Directory, string Content);
 
    private sealed class ContentSnapshotter : IPipelineOutputSnapshotter<ContentSnapshot>, IDisposable
    {
        public List<ContentSnapshot> Captures { get; } = [];
        public bool Disposed { get; private set; }
 
        public async Task<ContentSnapshot> CaptureAsync(string outputDirectory, CancellationToken ct)
        {
            Assert.False(Disposed);
            var snapshot = new ContentSnapshot(outputDirectory,
                await File.ReadAllTextAsync(Path.Combine(outputDirectory, "artifact.txt"), ct));
            Captures.Add(snapshot);
            return snapshot;
        }
 
        public void Dispose() => Disposed = true;
    }
 
    private sealed class ContentComparer : IPipelineOutputComparer<ContentSnapshot>
    {
        public List<(ContentSnapshot Before, ContentSnapshot After)> Comparisons { get; } = [];
 
        public Task<IReadOnlyList<PipelineOutputChange>> CompareAsync(ContentSnapshot before, ContentSnapshot after, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Comparisons.Add((before, after));
            return Task.FromResult<IReadOnlyList<PipelineOutputChange>>(
                before.Content == after.Content ? [] : [new(PipelineOutputChangeKind.Changed, "artifact.txt")]);
        }
    }
}
 
#pragma warning restore ASPIREPIPELINES005