File: Pipelines\PipelineOutputDiffingTests.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.Collections.Concurrent;
using System.IO.Hashing;
using System.Text;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
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 PipelineOutputDiffingTests(ITestOutputHelper output)
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void DiffServicesAndConfigurationAreOptIn(bool enabled)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path);
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Pipeline:OutputDiff:Mode"] = "FileSystem",
            ["Pipeline:OutputDiff:Rules:0:Action"] = "Include",
            ["Pipeline:OutputDiff:Rules:0:Pattern"] = "generated/**"
        });
        if (enabled)
        {
            builder.WithOutputDiffing();
        }
        using var app = builder.Build();
        var snapshotter = app.Services.GetService<IPipelineOutputSnapshotter<PipelineOutputSnapshot>>();
        var comparer = app.Services.GetService<IPipelineOutputComparer<PipelineOutputSnapshot>>();
        var options = app.Services.GetRequiredService<IOptions<PipelineOutputDiffOptions>>().Value;
 
        if (enabled)
        {
            Assert.IsType<DefaultPipelineOutputSnapshotter>(snapshotter);
            Assert.IsType<DefaultPipelineOutputComparer>(comparer);
            Assert.Equal(PipelineOutputDiffMode.FileSystem, options.Mode);
            Assert.Equal("generated/**", Assert.Single(options.Rules).Pattern);
        }
        else
        {
            Assert.Null(snapshotter);
            Assert.Null(comparer);
            Assert.Equal(PipelineOutputDiffMode.Auto, options.Mode);
            Assert.Empty(options.Rules);
        }
    }
 
    [Fact]
    public void MultipleChecksBindConfigurationOnceAndPreserveConfigureCallbacks()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path);
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Pipeline:OutputDiff:Mode"] = "Git",
            ["Pipeline:OutputDiff:Rules:0:Action"] = "Include",
            ["Pipeline:OutputDiff:Rules:0:Pattern"] = "generated/**"
        });
        builder.WithOutputDiffing();
        builder.Services.Configure<PipelineOutputDiffOptions>(options =>
        {
            options.Mode = PipelineOutputDiffMode.FileSystem;
            options.Rules[0].Pattern = "artifacts/**";
        });
        builder.WithOutputDiffing(diffStepName: "second-check");
        using var app = builder.Build();
        var options = app.Services.GetRequiredService<IOptions<PipelineOutputDiffOptions>>().Value;
 
        Assert.Equal(PipelineOutputDiffMode.FileSystem, options.Mode);
        Assert.Equal("artifacts/**", Assert.Single(options.Rules).Pattern);
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(true, true)]
    public async Task RegisteredFileHasherDefinesTextAndMetadataEquivalence(bool changeContent, bool changeMetadata)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var generated = workspace.CreateDirectory("generated").FullName;
        var artifact = Path.Combine(generated, "deployment.yaml");
        await File.WriteAllTextAsync(artifact, "services: [api]\r\n", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Services.Configure<PipelineOutputDiffOptions>(options =>
        {
            options.Mode = PipelineOutputDiffMode.FileSystem;
            options.Rules.Add(new() { Action = "Include", Pattern = "generated/**" });
        });
        builder.Services.PostConfigure<PipelineOutputDiffOptions>(options =>
            options.Rules.Add(new() { Action = "Exclude", Pattern = "**/*.tmp" }));
        var calls = 0;
        var hasher = new TestPipelineOutputFileHasher(async (entry, ct) =>
        {
            var file = Assert.IsType<FileInfo>(entry);
            Interlocked.Increment(ref calls);
            Assert.Equal(artifact, file.FullName);
            Assert.True(file.Exists);
            Assert.Equal(new FileInfo(artifact).Length, file.Length);
            Assert.Equal(File.GetAttributes(artifact), file.Attributes);
            using var reader = File.OpenText(file.FullName);
            var content = await reader.ReadToEndAsync(ct);
            var readOnly = (file.Attributes & FileAttributes.ReadOnly) != 0;
            return XxHash3.Hash(Encoding.UTF8.GetBytes(content.ReplaceLineEndings("\n") + readOnly));
        });
        builder.Services.AddSingleton<IPipelineOutputFileHasher>(hasher);
        builder.WithOutputDiffing();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, async _ =>
        {
            await File.WriteAllTextAsync(artifact, changeContent ? "services: [other]\n" : "services: [api]\n", TestContext.Current.CancellationToken);
            if (changeMetadata)
            {
                File.SetAttributes(artifact, File.GetAttributes(artifact) | FileAttributes.ReadOnly);
            }
        });
        using var app = builder.Build();
        Assert.Equal(0, calls);
        Assert.Equal("services: [api]\r\n", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
 
        Assert.Equal(0, (int)(File.GetAttributes(artifact) & FileAttributes.ReadOnly));
        try
        {
            if (changeContent || changeMetadata)
            {
                var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
                Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: " + Path.Combine("generated", "deployment.yaml"), failure.Message);
            }
            else
            {
                await ExecuteAsync(app);
            }
            Assert.Equal(changeMetadata, (File.GetAttributes(artifact) & FileAttributes.ReadOnly) != 0);
        }
        finally
        {
            File.SetAttributes(artifact, File.GetAttributes(artifact) & ~FileAttributes.ReadOnly);
        }
        Assert.Equal(2, calls);
        Assert.Equal(changeContent ? "services: [other]\n" : "services: [api]\n", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Theory]
    [InlineData(0, false)]
    [InlineData(0, true)]
    [InlineData(257, false)]
    [InlineData(257, true)]
    [InlineData(81920, false)]
    [InlineData(81920, true)]
    [InlineData(81921, false)]
    [InlineData(81921, true)]
    [InlineData(1048593, false)]
    [InlineData(1048593, true)]
    public async Task RegisteredByteFileHasherStreamsBinaryOutputAndDisposesStreams(int length, bool changed)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "artifact.bin");
        var control = Path.Combine(workspace.Path, "control.bin");
        var bytes = new byte[length];
        new Random(42).NextBytes(bytes);
        await File.WriteAllBytesAsync(artifact, bytes, TestContext.Current.CancellationToken);
        await File.WriteAllBytesAsync(control, [0, 255, 13, 10, 128], TestContext.Current.CancellationToken);
        var generated = bytes.ToArray();
        if (changed)
        {
            if (length == 0)
            {
                generated = [255];
            }
            else
            {
                generated[^1] ^= 255;
            }
            Assert.False(bytes.SequenceEqual(generated));
        }
        else
        {
            Assert.Equal(bytes, generated);
        }
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Services.AddSingleton<IPipelineOutputFileHasher, ByteOutputFileHasher>();
        builder.Services.Configure<PipelineOutputDiffOptions>(options => options.Mode = PipelineOutputDiffMode.FileSystem);
        builder.WithOutputDiffing();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, _ =>
            File.WriteAllBytesAsync(artifact, generated, TestContext.Current.CancellationToken));
        using var app = builder.Build();
        var hasher = Assert.IsType<ByteOutputFileHasher>(app.Services.GetRequiredService<IPipelineOutputFileHasher>());
        Assert.Empty(hasher.Streams);
        Assert.Equal(bytes, await File.ReadAllBytesAsync(artifact, TestContext.Current.CancellationToken));
 
        if (changed)
        {
            var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
            Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: artifact.bin", failure.Message);
        }
        else
        {
            await ExecuteAsync(app);
        }
 
        Assert.Equal(generated, await File.ReadAllBytesAsync(artifact, TestContext.Current.CancellationToken));
        Assert.Equal(new byte[] { 0, 255, 13, 10, 128 }, await File.ReadAllBytesAsync(control, TestContext.Current.CancellationToken));
        Assert.Equal(4, hasher.Streams.Count);
        Assert.All(hasher.Streams, stream => Assert.False(stream.CanRead));
        using var exclusive = new FileStream(artifact, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
        Assert.Equal(generated.Length, exclusive.Length);
    }
 
    [Fact]
    public async Task BaselinePrecedesWriterWithoutPublishPrerequisite()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "early.txt");
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        var snapshotter = new RecordingSnapshotter(artifact);
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<RecordingSnapshot>>(snapshotter);
        builder.Pipeline.AddStep("unordered-writer", _ =>
        {
            Assert.Equal(new[] { "capture" }, snapshotter.Events);
            return File.WriteAllTextAsync(artifact, "written after baseline", TestContext.Current.CancellationToken);
        }, requiredBy: WellKnownPipelineSteps.Publish);
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>();
        using var app = builder.Build();
        Assert.Empty(snapshotter.Events);
        Assert.False(File.Exists(artifact));
 
        await ExecuteAsync(app);
 
        Assert.Equal(new[] { "capture", "capture", "compare" }, snapshotter.Events);
        Assert.Equal("written after baseline", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task SimpleAndFactoryChecksPreserveMetadataAndIncludeTheirActions(bool useFactory, bool useSequential)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "factory.txt");
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "check-alias");
        var resource = builder.AddResource(new OutputResource("files"));
        builder.Pipeline.AddStep("custom-target", _ => Task.CompletedTask);
        builder.Pipeline.AddStep("check-alias", _ => Task.CompletedTask);
        Func<PipelineStepContext, Task> action = _ => File.WriteAllTextAsync(artifact, "factory action", TestContext.Current.CancellationToken);
        if (useFactory)
        {
            builder.WithOutputDiffing(() => new PipelineStep
            {
                Name = "custom-check",
                Description = "Check custom output",
                Action = action,
                DependsOnSteps = ["custom-target"],
                RequiredBySteps = ["check-alias"],
                Tags = ["custom-tag"],
                Resource = resource.Resource
            });
        }
        else
        {
            builder.WithOutputDiffing("custom-target", "custom-check", "Check custom output");
            builder.Pipeline.WithFinalAction("custom-target", action);
            builder.Pipeline.AddStep("select-check", _ => Task.CompletedTask, dependsOn: "custom-check", requiredBy: "check-alias");
        }
        using var app = builder.Build();
        var pipeline = Assert.IsType<DistributedApplicationPipeline>(app.Services.GetRequiredService<IDistributedApplicationPipeline>());
        var context = new PipelineContext(app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(), app.Services,
            NullLogger.Instance, TestContext.Current.CancellationToken);
        var steps = await pipeline.ResolveStepsAsync(context);
        var check = Assert.Single(steps, step => step.Name == "custom-check");
        Assert.Equal("Check custom output", check.Description);
        Assert.Contains("custom-target", check.DependsOnSteps);
        Assert.Contains(steps, step => step.Name == "custom-check-baseline");
        if (useFactory)
        {
            Assert.Same(action, check.Action);
            Assert.Contains("custom-tag", check.Tags);
            Assert.Contains("check-alias", check.RequiredBySteps);
            Assert.Same(resource.Resource, check.Resource);
        }
        Assert.False(File.Exists(artifact));
 
        var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => useSequential
            ? pipeline.ExecuteStepSequentiallyAsync("check-alias", context)
            : ExecuteAsync(app));
 
        Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Added: factory.txt", failure.Message);
        Assert.Equal("factory action", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task InspectionDoesNotResolveCaptureServices()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Services.AddTransient<IPipelineOutputSnapshotter<PipelineOutputSnapshot>>(_ => throw new InvalidOperationException("typed snapshotter resolved"));
        builder.Services.AddTransient<IPipelineOutputComparer<PipelineOutputSnapshot>>(_ => throw new InvalidOperationException("comparer resolved"));
        builder.Services.AddSingleton<PipelineOutputSnapshotter>(_ => throw new InvalidOperationException("snapshotter resolved"));
        builder.Services.AddSingleton<IPipelineOutputService>(_ => throw new InvalidOperationException("output resolved"));
        builder.WithOutputDiffing();
        using var app = builder.Build();
        var pipeline = Assert.IsType<DistributedApplicationPipeline>(app.Services.GetRequiredService<IDistributedApplicationPipeline>());
        var context = new PipelineContext(app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(), app.Services,
            NullLogger.Instance, TestContext.Current.CancellationToken);
 
        var steps = await pipeline.ResolveStepsAsync(context);
 
        Assert.Contains(steps, step => step.Name == "publish-and-diff");
        Assert.Empty(Assert.Single(steps, step => step.Name == "publish-and-diff-baseline").DependsOnSteps);
    }
 
    [Fact]
    public async Task LateResourceConfigurationCannotRemoveBaselineOrdering()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "late.txt");
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        var snapshotter = new RecordingSnapshotter(artifact);
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<RecordingSnapshot>>(snapshotter);
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>();
        builder.AddResource(new OutputResource("late-writer"))
            .WithPipelineStepFactory(_ => new PipelineStep
            {
                Name = "late-writer",
                Action = _ =>
                {
                    Assert.Equal(new[] { "capture" }, snapshotter.Events);
                    return File.WriteAllTextAsync(artifact, "late output", TestContext.Current.CancellationToken);
                },
                DependsOnSteps = [WellKnownPipelineSteps.PublishPrereq]
            })
            .WithPipelineConfiguration(context =>
            {
                var writer = Assert.Single(context.Steps, step => step.Name == "late-writer");
                Assert.Contains(WellKnownPipelineSteps.PublishPrereq, writer.DependsOnSteps);
                writer.DependsOnSteps.Clear();
                writer.RequiredBy(WellKnownPipelineSteps.Publish);
            });
        using var app = builder.Build();
        Assert.Empty(snapshotter.Events);
        Assert.False(File.Exists(artifact));
 
        await ExecuteAsync(app);
 
        Assert.Equal(new[] { "capture", "capture", "compare" }, snapshotter.Events);
        Assert.Equal("late output", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task OrdinaryTargetDoesNotResolveAnyDiffServices()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path);
        builder.Services.AddTransient<IPipelineOutputSnapshotter<PipelineOutputSnapshot>>(_ => throw new InvalidOperationException("typed snapshotter resolved"));
        builder.Services.AddTransient<IPipelineOutputComparer<PipelineOutputSnapshot>>(_ => throw new InvalidOperationException("comparer resolved"));
        builder.Services.AddSingleton<PipelineOutputSnapshotter>(_ => throw new InvalidOperationException("snapshotter resolved"));
        builder.Services.AddSingleton<IPipelineOutputService>(_ => throw new InvalidOperationException("output resolved"));
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing();
        var artifact = Path.Combine(workspace.Path, "ordinary.txt");
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, _ => File.WriteAllTextAsync(artifact, "ordinary output", TestContext.Current.CancellationToken));
        using var app = builder.Build();
        Assert.False(File.Exists(artifact));
 
        await ExecuteAsync(app);
 
        Assert.Equal("ordinary output", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task FreshGitCheckoutIgnoresBuildOutputsButChecksTrackedArtifacts(bool changeArtifact)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "tracked.tmp");
        await File.WriteAllTextAsync(Path.Combine(workspace.Path, ".gitignore"), "**/bin/\n**/obj/\n*.tmp\n", TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(artifact, "indexed", TestContext.Current.CancellationToken);
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "init");
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "config", "core.excludesFile", Path.Combine(workspace.Path, ".git", "info", "exclude"));
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "add", "-f", "--", ".gitignore", "tracked.tmp");
        await File.WriteAllTextAsync(artifact, "already dirty", TestContext.Current.CancellationToken);
        Assert.Contains("tracked.tmp", await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "diff", "--name-only"));
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, async _ =>
        {
            Assert.False(Directory.Exists(Path.Combine(workspace.Path, "src", "Api", "bin")));
            Assert.False(Directory.Exists(Path.Combine(workspace.Path, "src", "Api", "obj")));
            var binaryDirectory = workspace.CreateDirectory("src/Api/bin/Release");
            var objectDirectory = workspace.CreateDirectory("src/Api/obj");
            await File.WriteAllTextAsync(Path.Combine(binaryDirectory.FullName, "Api.dll"), "build output", TestContext.Current.CancellationToken);
            await File.WriteAllTextAsync(Path.Combine(objectDirectory.FullName, "project.assets.json"), "{}", TestContext.Current.CancellationToken);
            if (changeArtifact)
            {
                await File.WriteAllTextAsync(artifact, "generated change", TestContext.Current.CancellationToken);
            }
        });
        using var app = builder.Build();
        Assert.False(Directory.Exists(Path.Combine(workspace.Path, "src")));
 
        if (changeArtifact)
        {
            var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
            Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: " + Path.GetRelativePath(workspace.Path, artifact), failure.Message);
        }
        else
        {
            await ExecuteAsync(app);
        }
 
        Assert.Equal(changeArtifact ? "generated change" : "already dirty", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        Assert.True(File.Exists(Path.Combine(workspace.Path, "src", "Api", "bin", "Release", "Api.dll")));
        Assert.True(File.Exists(Path.Combine(workspace.Path, "src", "Api", "obj", "project.assets.json")));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task DefaultPolicyChecksGenerationOnlyWithUnchangedSource(bool staleArtifact)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var sourceDirectory = workspace.CreateDirectory("src/Api").FullName;
        var source = Path.Combine(sourceDirectory, "Program.cs");
        var artifact = Path.Combine(workspace.CreateDirectory("generated").FullName, "deployment.yaml");
        const string sourceText = "var builder = WebApplication.CreateBuilder(args);";
        const string generatedText = "services: [api]";
        await File.WriteAllTextAsync(source, sourceText, TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(artifact, staleArtifact ? "services: [old-api]" : generatedText, TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(Path.Combine(workspace.Path, ".gitignore"), "**/bin/\n**/obj/\n", TestContext.Current.CancellationToken);
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "init");
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "config", "core.excludesFile", Path.Combine(workspace.Path, ".git", "info", "exclude"));
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "add", "--", ".gitignore", "src", "generated");
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing();
        var generationCalls = 0;
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, async _ =>
        {
            Assert.Equal(sourceText, await File.ReadAllTextAsync(source, TestContext.Current.CancellationToken));
            Assert.False(Directory.Exists(Path.Combine(sourceDirectory, "bin")));
            Assert.False(Directory.Exists(Path.Combine(sourceDirectory, "obj")));
            generationCalls++;
            await File.WriteAllTextAsync(artifact, generatedText, TestContext.Current.CancellationToken);
        });
        using var app = builder.Build();
        Assert.Equal(0, generationCalls);
        Assert.Equal(staleArtifact ? "services: [old-api]" : generatedText, await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
 
        if (staleArtifact)
        {
            var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
            Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: " + Path.GetRelativePath(workspace.Path, artifact), failure.Message);
        }
        else
        {
            await ExecuteAsync(app);
        }
 
        Assert.Equal(1, generationCalls);
        Assert.Equal(generatedText, await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        Assert.Equal(sourceText, await File.ReadAllTextAsync(source, TestContext.Current.CancellationToken));
        Assert.False(Directory.Exists(Path.Combine(sourceDirectory, "bin")));
        Assert.False(Directory.Exists(Path.Combine(sourceDirectory, "obj")));
    }
 
    [Theory]
    [InlineData("json", false)]
    [InlineData("json", true)]
    [InlineData("keys", false)]
    [InlineData("keys", true)]
    [InlineData("options", false)]
    [InlineData("options", true)]
    public async Task ConfiguredPositiveScopeChecksArtifactsButNotSource(string configuration, bool changeArtifact)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = Path.Combine(workspace.CreateDirectory("src").FullName, "Program.cs");
        var generated = workspace.CreateDirectory("generated").FullName;
        var artifact = Path.Combine(generated, "deployment.yaml");
        var temporary = Path.Combine(generated, "cache.tmp");
        foreach (var path in new[] { source, artifact, temporary })
        {
            await File.WriteAllTextAsync(path, "old", TestContext.Current.CancellationToken);
        }
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Configuration.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes("""
            {"Pipeline":{"OutputDiff":{"Mode":"FileSystem","Rules":[
              {"Action":"Include","Pattern":"src/**"}
            ]}}}
            """)));
        builder.WithOutputDiffing();
        if (configuration == "json")
        {
            builder.Configuration.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes("""
                {"Pipeline":{"OutputDiff":{"Rules":[
                  {"Action":"Include","Pattern":"generated/**"},
                  {"Action":"Exclude","Pattern":"**/*.tmp"}
                ]}}}
                """)));
        }
        else if (configuration == "keys")
        {
            // Normalized environment-provider keys override one array entry, not the whole array.
            builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Pipeline:OutputDiff:Rules:0:Pattern"] = "generated/**",
                ["Pipeline:OutputDiff:Rules:1:Action"] = "Exclude",
                ["Pipeline:OutputDiff:Rules:1:Pattern"] = "**/*.tmp"
            });
        }
        else if (configuration == "options")
        {
            builder.Services.Configure<PipelineOutputDiffOptions>(options => options.Rules[0].Pattern = "generated/**");
            builder.Services.PostConfigure<PipelineOutputDiffOptions>(options => options.Rules.Add(new() { Action = "Exclude", Pattern = "**/*.tmp" }));
        }
        AddArtifactWriter(builder.Pipeline);
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, async _ =>
        {
            await File.WriteAllTextAsync(source, "new", TestContext.Current.CancellationToken);
            await File.WriteAllTextAsync(temporary, "new", TestContext.Current.CancellationToken);
            if (changeArtifact)
            {
                await File.WriteAllTextAsync(artifact, "new", TestContext.Current.CancellationToken);
            }
        });
        using var app = builder.Build();
        Assert.Equal("old", await File.ReadAllTextAsync(source, TestContext.Current.CancellationToken));
        Assert.Equal("old", await File.ReadAllTextAsync(temporary, TestContext.Current.CancellationToken));
        Assert.Equal("old", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
 
        if (changeArtifact)
        {
            var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
            Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: " + Path.GetRelativePath(workspace.Path, artifact), failure.Message);
        }
        else
        {
            await ExecuteAsync(app);
        }
 
        Assert.Equal("new", await File.ReadAllTextAsync(source, TestContext.Current.CancellationToken));
        Assert.Equal("new", await File.ReadAllTextAsync(temporary, TestContext.Current.CancellationToken));
        Assert.Equal(changeArtifact ? "new" : "old", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Theory]
    [InlineData(PipelineOutputDiffMode.Auto)]
    [InlineData(PipelineOutputDiffMode.Git)]
    [InlineData(PipelineOutputDiffMode.FileSystem)]
    public async Task ConfiguredIncludeOfIgnoredArtifactRequiresGitOptOut(PipelineOutputDiffMode mode)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.CreateDirectory("generated").FullName, "deployment.yaml");
        await File.WriteAllTextAsync(artifact, "old", TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(Path.Combine(workspace.Path, ".gitignore"), "generated/\n", TestContext.Current.CancellationToken);
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "init");
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "config", "core.excludesFile", Path.Combine(workspace.Path, ".git", "info", "exclude"));
        Assert.Equal("generated/deployment.yaml", (await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "check-ignore", "--", "generated/deployment.yaml")).Trim());
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Pipeline:OutputDiff:Mode"] = mode.ToString(),
            ["Pipeline:OutputDiff:Rules:0:Action"] = "Include",
            ["Pipeline:OutputDiff:Rules:0:Pattern"] = "generated/**"
        });
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, _ => File.WriteAllTextAsync(artifact, "new", TestContext.Current.CancellationToken));
        using var app = builder.Build();
        Assert.Equal("old", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
 
        if (mode is PipelineOutputDiffMode.Auto or PipelineOutputDiffMode.Git)
        {
            await ExecuteAsync(app);
        }
        else
        {
            var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
            Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: " + Path.GetRelativePath(workspace.Path, artifact), failure.Message);
        }
 
        Assert.Equal("new", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task FilesystemModeIncludesIgnoredArtifactsAndDetectsRawEolChanges(bool changeContent)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.CreateDirectory("generated").FullName, "deployment.yaml");
        const string before = "services: [api]\n";
        var after = changeContent ? "services: [other-api]\r\n" : "services: [api]\r\n";
        await File.WriteAllTextAsync(artifact, before, TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(Path.Combine(workspace.Path, ".gitattributes"), "*.yaml text eol=lf\n", TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(Path.Combine(workspace.Path, ".gitignore"), "generated/\n", TestContext.Current.CancellationToken);
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "init");
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "config", "core.autocrlf", "false");
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "config", "core.safecrlf", "false");
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "config", "core.excludesFile", Path.Combine(workspace.Path, ".git", "info", "exclude"));
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "add", "--", ".gitattributes", ".gitignore");
        Assert.Equal("generated/deployment.yaml", (await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "check-ignore", "--", "generated/deployment.yaml")).Trim());
        var beforeObject = await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "hash-object", "--", artifact);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Pipeline:OutputDiff:Mode"] = "FileSystem",
            ["Pipeline:OutputDiff:Rules:0:Action"] = "Include",
            ["Pipeline:OutputDiff:Rules:0:Pattern"] = "generated/**"
        });
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, _ => File.WriteAllTextAsync(artifact, after, TestContext.Current.CancellationToken));
        using var app = builder.Build();
        Assert.Equal(before, await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        Assert.NotEqual(before, after);
 
        var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
        Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: " + Path.GetRelativePath(workspace.Path, artifact), failure.Message);
 
        Assert.Equal(after, await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        var afterObject = await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "hash-object", "--", artifact);
        Assert.Equal(!changeContent, beforeObject == afterObject);
    }
 
    [Fact]
    public async Task CustomProviderOwnsPolicyWithoutGitOrValidNativeOptions()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "stable.txt");
        await File.WriteAllTextAsync(artifact, "stable", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Pipeline:OutputDiff:Rules:0:Action"] = "CustomPolicy",
            ["Pipeline:OutputDiff:Rules:0:Pattern"] = "../outside-native-scope/**"
        });
        var snapshotter = new RecordingSnapshotter(artifact, reportUnchanged: true);
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<RecordingSnapshot>>(snapshotter);
        builder.Services.AddSingleton<PipelineOutputSnapshotter>(_ => throw new InvalidOperationException("Native snapshotter resolved by replacement policy."));
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>();
        using var app = builder.Build();
        Assert.Empty(snapshotter.Events);
        Assert.False(Directory.Exists(Path.Combine(workspace.Path, ".git")));
 
        var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
 
        Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Changed: " + Path.GetRelativePath(workspace.Path, artifact), failure.Message);
        Assert.Equal(new[] { "capture", "capture", "compare" }, snapshotter.Events);
        Assert.Equal("stable", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task GlobalOnlyCaptureDoesNotResolveExternalResourceDirectories()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var global = workspace.CreateDirectory("global").FullName;
        var external = workspace.CreateDirectory("external").FullName;
        var artifact = Path.Combine(external, "artifact.txt");
        await File.WriteAllTextAsync(artifact, "old", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: global, step: "publish-and-diff");
        builder.AddResource(new OutputResource("files"));
        var provider = new CountingOutputService(global, external);
        builder.Services.AddSingleton<IPipelineOutputService>(provider);
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, _ => File.WriteAllTextAsync(artifact, "new", TestContext.Current.CancellationToken));
        using var app = builder.Build();
        Assert.Equal("old", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
 
        await ExecuteAsync(app);
 
        Assert.Equal(2, provider.GlobalCalls);
        Assert.Equal(0, provider.ResourceCalls);
        Assert.Equal("new", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task NativeDiffIncludesPublishFinalActionsAndRetainsOutput()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var changed = Path.Combine(workspace.Path, "changed.txt");
        var removed = Path.Combine(workspace.Path, "removed.txt");
        var added = Path.Combine(workspace.Path, "added.txt");
        await File.WriteAllTextAsync(changed, "before", TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(removed, "remove me", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, async _ =>
        {
            await File.WriteAllTextAsync(changed, "after", TestContext.Current.CancellationToken);
            await File.WriteAllTextAsync(added, "added", TestContext.Current.CancellationToken);
            File.Delete(removed);
        });
        using var app = builder.Build();
        Assert.Equal("before", await File.ReadAllTextAsync(changed, TestContext.Current.CancellationToken));
        Assert.True(File.Exists(removed));
        Assert.False(File.Exists(added));
 
        var report = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
        Assert.Equal($"""
            Pipeline output changed:
              Added: {Path.GetRelativePath(workspace.Path, added)}
              Changed: {Path.GetRelativePath(workspace.Path, changed)}
              Removed: {Path.GetRelativePath(workspace.Path, removed)}
            """.ReplaceLineEndings(), report.Message);
        Assert.Equal("after", await File.ReadAllTextAsync(changed, TestContext.Current.CancellationToken));
        Assert.Equal("added", await File.ReadAllTextAsync(added, TestContext.Current.CancellationToken));
        Assert.False(File.Exists(removed));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task EachTransientCaptureIsDisposedOnlyByHost(bool reportUnchanged)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "unchanged.txt");
        await File.WriteAllTextAsync(artifact, "stable", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        var instances = new ConcurrentBag<RecordingSnapshotter>();
        builder.Services.AddTransient<IPipelineOutputSnapshotter<RecordingSnapshot>>(_ =>
        {
            var instance = new RecordingSnapshotter(artifact, reportUnchanged);
            instances.Add(instance);
            return instance;
        });
        builder.Services.AddSingleton<PipelineOutputSnapshotter>(_ => throw new InvalidOperationException("Native engine must not resolve for a replacement."));
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>();
        using var app = builder.Build();
        Assert.Empty(instances);
 
        if (reportUnchanged)
        {
            var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
            Assert.Contains("Changed: " + Path.GetRelativePath(workspace.Path, artifact), failure.ToString());
        }
        else
        {
            await ExecuteAsync(app);
        }
 
        Assert.Equal(2, instances.Count);
        Assert.Single(instances, snapshotter => snapshotter.Events.SequenceEqual(new[] { "capture" }));
        Assert.Single(instances, snapshotter => snapshotter.Events.SequenceEqual(new[] { "capture", "compare" }));
        Assert.Equal("stable", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        Assert.All(instances, snapshotter => Assert.False(snapshotter.Disposed));
        app.Dispose();
        Assert.All(instances, snapshotter => Assert.True(snapshotter.Disposed));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AliasedFactoryChecksResolveSeparateCapturesWithoutBaselineCycles(bool selectBoth)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "stable.txt");
        await File.WriteAllTextAsync(artifact, "stable", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: selectBoth ? "both-checks" : "first-check");
        var instances = new ConcurrentBag<RecordingSnapshotter>();
        var comparers = new ConcurrentBag<RecordingComparer>();
        builder.Services.AddTransient<IPipelineOutputSnapshotter<RecordingSnapshot>>(_ =>
        {
            var snapshotter = new RecordingSnapshotter(artifact);
            instances.Add(snapshotter);
            return snapshotter;
        });
        builder.Services.AddTransient<IPipelineOutputComparer<RecordingSnapshot>>(_ =>
        {
            var comparer = new RecordingComparer();
            comparers.Add(comparer);
            return comparer;
        });
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>(() => new PipelineStep
        {
            Name = "first-check",
            Action = _ => Task.CompletedTask,
            DependsOnSteps = [WellKnownPipelineSteps.Publish]
        });
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>(() => new PipelineStep
        {
            Name = "second-check",
            Action = _ => Task.CompletedTask,
            DependsOnSteps = [WellKnownPipelineSteps.Publish]
        });
        builder.Pipeline.AddStep("both-checks", _ => Task.CompletedTask, dependsOn: new[] { "first-check", "second-check" });
        using var app = builder.Build();
        Assert.Empty(instances);
        Assert.Empty(comparers);
 
        await ExecuteAsync(app);
 
        Assert.Equal(selectBoth ? 4 : 2, instances.Count);
        Assert.Equal(selectBoth ? 2 : 1, comparers.Count);
        if (selectBoth)
        {
            var selected = instances.ToArray();
            var selectedComparers = comparers.ToArray();
            Assert.NotSame(selected[0], selected[1]);
            Assert.NotSame(selectedComparers[0], selectedComparers[1]);
        }
        Assert.Equal(selectBoth ? 2 : 1, instances.Count(snapshotter => snapshotter.Events.SequenceEqual(new[] { "capture" })));
        Assert.Equal(selectBoth ? 2 : 1, instances.Count(snapshotter => snapshotter.Events.SequenceEqual(new[] { "capture", "compare" })));
        Assert.Equal("stable", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        Assert.All(instances, snapshotter => Assert.False(snapshotter.Disposed));
        Assert.All(comparers, comparer => Assert.False(comparer.Disposed));
        app.Dispose();
        Assert.All(instances, snapshotter => Assert.True(snapshotter.Disposed));
        Assert.All(comparers, comparer => Assert.True(comparer.Disposed));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task FailedBaselineOrTargetPreventsLaterCapturesAndComparison(bool failBaseline)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "result.txt");
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        var snapshotter = new RecordingSnapshotter(artifact) { FailBefore = failBaseline };
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<RecordingSnapshot>>(snapshotter);
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>();
        builder.Pipeline.AddStep("writer", async _ =>
        {
            await File.WriteAllTextAsync(artifact, "target entered", TestContext.Current.CancellationToken);
            throw new IOException("target failed");
        }, dependsOn: WellKnownPipelineSteps.PublishPrereq, requiredBy: WellKnownPipelineSteps.Publish);
        using var app = builder.Build();
        Assert.False(File.Exists(artifact));
 
        var failure = await Assert.ThrowsAnyAsync<Exception>(() => ExecuteAsync(app));
 
        Assert.True(failure is InvalidOperationException or AggregateException, failure.ToString());
        Assert.Contains(failBaseline ? "baseline failed" : "target failed", failure.ToString());
        Assert.Equal(new[] { "capture" }, snapshotter.Events);
        Assert.Equal(!failBaseline, File.Exists(artifact));
        if (!failBaseline)
        {
            Assert.Equal("target entered", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        }
        app.Dispose();
        Assert.False(snapshotter.Disposed); // An externally supplied instance is owned by its caller.
    }
 
    [Fact]
    public async Task CustomTargetNeedsNoExplicitBaseline()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "custom.txt");
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "custom-check");
        var snapshotter = new RecordingSnapshotter(artifact);
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<RecordingSnapshot>>(snapshotter);
        builder.Pipeline.AddStep("custom-target", _ =>
        {
            Assert.Equal(new[] { "capture" }, snapshotter.Events);
            return File.WriteAllTextAsync(artifact, "custom output", TestContext.Current.CancellationToken);
        });
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>("custom-target", "custom-check");
        using var app = builder.Build();
        Assert.False(File.Exists(artifact));
        Assert.Empty(snapshotter.Events);
 
        await ExecuteAsync(app);
 
        Assert.Equal(new[] { "capture", "capture", "compare" }, snapshotter.Events);
        Assert.Equal("custom output", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task CancellationFromSnapshotterStopsTheTarget()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "cancelled.txt");
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();
        var snapshotter = new RecordingSnapshotter(artifact) { BeforeCancellation = cancellation.Token };
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<RecordingSnapshot>>(snapshotter);
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>();
        builder.Pipeline.AddStep("writer", _ => File.WriteAllTextAsync(artifact, "unexpected", TestContext.Current.CancellationToken),
            dependsOn: WellKnownPipelineSteps.PublishPrereq, requiredBy: WellKnownPipelineSteps.Publish);
        using var app = builder.Build();
        Assert.False(File.Exists(artifact));
 
        var failure = await Assert.ThrowsAnyAsync<Exception>(() => ExecuteAsync(app));
 
        IEnumerable<Exception> failures = failure is AggregateException aggregate ? aggregate.Flatten().InnerExceptions : [failure];
        Assert.Contains(failures, exception =>
            exception.InnerException is OperationCanceledException cancelled && cancelled.CancellationToken == cancellation.Token);
        Assert.Equal(new[] { "capture" }, snapshotter.Events);
        Assert.False(File.Exists(artifact));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task EachDefaultTransientReadsCurrentRuleValues(bool requireGit)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var included = Path.Combine(workspace.Path, "included.txt");
        var excluded = Path.Combine(workspace.Path, "excluded.tmp");
        await File.WriteAllTextAsync(included, "old", TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(excluded, "old", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Services.Configure<PipelineOutputDiffOptions>(options =>
        {
            options.Mode = PipelineOutputDiffMode.FileSystem;
            options.Rules.Add(new() { Action = "Include", Pattern = "**/*" });
            options.Rules.Add(new() { Action = "Exclude", Pattern = "*.tmp" });
        });
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, async context =>
        {
            var options = context.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<PipelineOutputDiffOptions>>().Value;
            Assert.Equal(new[] { "**/*", "*.tmp" }, options.Rules.Select(rule => rule.Pattern));
            var rule = Assert.Single(options.Rules, rule => rule.Action == "Exclude");
            Assert.Equal("Exclude", rule.Action);
            Assert.Equal("*.tmp", rule.Pattern);
            rule.Action = "Include";
            rule.Pattern = "**/*";
            options.Rules.Clear();
            options.Rules.Add(new() { Action = "Exclude", Pattern = "included.txt" });
            options.Mode = requireGit ? PipelineOutputDiffMode.Git : PipelineOutputDiffMode.FileSystem;
            await File.WriteAllTextAsync(included, "new", TestContext.Current.CancellationToken);
            await File.WriteAllTextAsync(excluded, "new", TestContext.Current.CancellationToken);
        });
        using var app = builder.Build();
        Assert.Equal("old", await File.ReadAllTextAsync(included, TestContext.Current.CancellationToken));
        Assert.Equal("old", await File.ReadAllTextAsync(excluded, TestContext.Current.CancellationToken));
 
        var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
 
        if (requireGit)
        {
            Assert.Contains("Git is required", failure.Message);
        }
        else
        {
            Assert.Equal("Pipeline output changed:" + Environment.NewLine + "  Removed: " + Path.GetRelativePath(workspace.Path, included), failure.Message);
        }
        Assert.Equal("new", await File.ReadAllTextAsync(excluded, TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task GitReplacementUsesIndexBaselineWithoutResolvingNativeSnapshotter()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var artifact = Path.Combine(workspace.Path, "tracked.txt");
        var added = Path.Combine(workspace.Path, "untracked.txt");
        var excluded = Path.Combine(workspace.Path, "excluded.tmp");
        await File.WriteAllTextAsync(artifact, "old", TestContext.Current.CancellationToken);
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "init");
        await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "add", "--", "tracked.txt");
        Assert.Empty(await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "diff", "--name-only"));
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "publish-and-diff");
        builder.Services.AddSingleton<PipelineOutputSnapshotter>(_ => throw new InvalidOperationException("Native snapshotter resolved by Git replacement."));
        builder.Services.AddTransient<IPipelineOutputSnapshotter<GitSnapshot>>(_ => new GitSnapshotter(workspace.Path));
        AddArtifactWriter(builder.Pipeline);
        builder.WithOutputDiffing<GitSnapshot, GitSnapshotter, GitComparer>();
        builder.Pipeline.WithFinalAction(WellKnownPipelineSteps.Publish, async _ =>
        {
            await File.WriteAllTextAsync(artifact, "new", TestContext.Current.CancellationToken);
            await File.WriteAllTextAsync(added, "untracked", TestContext.Current.CancellationToken);
            await File.WriteAllTextAsync(excluded, "excluded", TestContext.Current.CancellationToken);
        });
        using var app = builder.Build();
 
        var failure = await Assert.ThrowsAsync<DistributedApplicationException>(() => ExecuteAsync(app));
 
        Assert.Equal($"""
            Pipeline output changed:
              Changed: {Path.GetRelativePath(workspace.Path, artifact)}
              Added: {Path.GetRelativePath(workspace.Path, added)}
            """.ReplaceLineEndings(), failure.Message);
        Assert.Equal("new", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
        Assert.Equal("untracked", await File.ReadAllTextAsync(added, TestContext.Current.CancellationToken));
        Assert.Equal("excluded", await File.ReadAllTextAsync(excluded, TestContext.Current.CancellationToken));
        Assert.Equal("old", (await PipelineOutputGitTestHelper.RunAsync(workspace.Path, "show", ":tracked.txt")).Trim());
    }
 
    [Fact]
    public async Task DifferentSnapshotTypesUseIndependentProvidersAndFreshOutputDirectories()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var relocated = workspace.CreateDirectory("relocated").FullName;
        var artifact = Path.Combine(workspace.Path, "stable.txt");
        await File.WriteAllTextAsync(artifact, "stable", TestContext.Current.CancellationToken);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path, step: "both-checks");
        var snapshotter = new RecordingSnapshotter(artifact);
        var directories = new DirectorySnapshotter();
        var comparer = new DirectoryComparer(workspace.Path, relocated);
        var provider = new CountingOutputService(workspace.Path, relocated);
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<RecordingSnapshot>>(snapshotter);
        builder.Services.AddSingleton<IPipelineOutputSnapshotter<string>>(directories);
        builder.Services.AddSingleton<IPipelineOutputComparer<string>>(comparer);
        builder.Services.AddSingleton<IPipelineOutputService>(provider);
        builder.WithOutputDiffing<RecordingSnapshot, RecordingSnapshotter, RecordingComparer>(diffStepName: "file-check");
        var factoryCalls = 0;
        builder.WithOutputDiffing<string, DirectorySnapshotter, DirectoryComparer>(() => new PipelineStep
        {
            Name = "directory-check",
            Action = _ =>
            {
                Assert.Equal(new[] { workspace.Path }, directories.Directories);
                Assert.Equal(0, comparer.Comparisons);
                factoryCalls++;
                return Task.CompletedTask;
            },
            DependsOnSteps = [WellKnownPipelineSteps.Publish]
        });
        builder.Pipeline.AddStep("relocate-output", _ =>
        {
            Assert.Equal(new[] { "capture" }, snapshotter.Events);
            Assert.Equal(new[] { workspace.Path }, directories.Directories);
            provider.GlobalDirectory = relocated;
            return Task.CompletedTask;
        }, requiredBy: WellKnownPipelineSteps.Publish);
        builder.Pipeline.AddStep("both-checks", _ => Task.CompletedTask, dependsOn: new[] { "file-check", "directory-check" });
        using var app = builder.Build();
        Assert.Empty(snapshotter.Events);
        Assert.Empty(directories.Directories);
        Assert.Equal(0, comparer.Comparisons);
        Assert.Equal(0, factoryCalls);
        Assert.NotEqual(workspace.Path, relocated);
 
        await ExecuteAsync(app);
 
        Assert.Equal(new[] { "capture", "capture", "compare" }, snapshotter.Events);
        Assert.Equal(new[] { workspace.Path, relocated }, directories.Directories);
        Assert.Equal(1, comparer.Comparisons);
        Assert.Equal(1, factoryCalls);
        Assert.Equal(4, provider.GlobalCalls);
        Assert.Equal(0, provider.ResourceCalls);
        Assert.Equal("stable", await File.ReadAllTextAsync(artifact, TestContext.Current.CancellationToken));
    }
 
    private static void AddArtifactWriter(IDistributedApplicationPipeline pipeline) => pipeline.AddStep(
        "artifact-writer", _ => Task.CompletedTask,
        dependsOn: WellKnownPipelineSteps.PublishPrereq, requiredBy: WellKnownPipelineSteps.Publish);
 
    private static Task ExecuteAsync(DistributedApplication app) => app.Services.GetRequiredService<IDistributedApplicationPipeline>().ExecuteAsync(
        new PipelineContext(app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(), app.Services,
            NullLogger.Instance, TestContext.Current.CancellationToken));
 
    private sealed class OutputResource(string name) : Resource(name);
 
    private sealed record GitSnapshot(string OutputDirectory, IReadOnlyList<PipelineOutputChange> Changes);
 
    // Each capture reports working-tree changes against the index, rather than native file fingerprints.
    private sealed class GitSnapshotter(string directory) : IPipelineOutputSnapshotter<GitSnapshot>
    {
        public async Task<GitSnapshot> CaptureAsync(string outputDirectory, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Assert.Equal(directory, outputDirectory);
            var exclusions = new Microsoft.Extensions.FileSystemGlobbing.Matcher(StringComparison.Ordinal);
            exclusions.AddInclude("**/*");
            exclusions.AddExclude("*.tmp");
            List<PipelineOutputChange> changes = [];
            foreach (var (command, kind) in new[]
            {
                (new[] { "diff", "--name-only", "-z" }, PipelineOutputChangeKind.Changed),
                (new[] { "ls-files", "--others", "--exclude-standard", "-z" }, PipelineOutputChangeKind.Added)
            })
            {
                // Both commands emit paths separated by NUL, including names containing newlines.
                foreach (var path in (await PipelineOutputGitTestHelper.RunAsync(directory, command)).Split('\0', StringSplitOptions.RemoveEmptyEntries))
                {
                    if (Microsoft.Extensions.FileSystemGlobbing.MatcherExtensions.Match(exclusions, path).HasMatches)
                    {
                        changes.Add(new PipelineOutputChange(kind, path));
                    }
                }
            }
 
            return new GitSnapshot(outputDirectory, changes.AsReadOnly());
        }
    }
 
    private sealed class GitComparer : IPipelineOutputComparer<GitSnapshot>
    {
        public Task<IReadOnlyList<PipelineOutputChange>> CompareAsync(GitSnapshot before, GitSnapshot after, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Assert.Equal(before.OutputDirectory, after.OutputDirectory);
            // This policy uses the index as its baseline, not the earlier working-tree capture.
            return Task.FromResult(after.Changes);
        }
    }
 
    private sealed class CountingOutputService(string globalDirectory, string resourceDirectory) : IPipelineOutputService
    {
        private int _globalCalls;
        private int _resourceCalls;
        public int GlobalCalls => _globalCalls;
        public int ResourceCalls => _resourceCalls;
        public string GlobalDirectory { get; set; } = globalDirectory;
        public string GetOutputDirectory()
        {
            Interlocked.Increment(ref _globalCalls);
            return GlobalDirectory;
        }
        public string GetOutputDirectory(IResource resource)
        {
            Interlocked.Increment(ref _resourceCalls);
            return resourceDirectory;
        }
        public string GetTempDirectory() => throw new InvalidOperationException("No temporary lookup during diffing.");
        public string GetTempDirectory(IResource resource) => throw new InvalidOperationException("No temporary lookup during diffing.");
    }
 
    private sealed record RecordingSnapshot(RecordingSnapshotter Owner, string OutputDirectory, int Capture);
 
    private sealed class RecordingSnapshotter(string artifact, bool reportUnchanged = false) : IPipelineOutputSnapshotter<RecordingSnapshot>, IDisposable
    {
        public List<string> Events { get; } = [];
        public string Artifact { get; } = artifact;
        public bool ReportUnchanged { get; } = reportUnchanged;
        public bool FailBefore { get; init; }
        public CancellationToken BeforeCancellation { get; init; }
        public bool Disposed { get; private set; }
 
        public Task<RecordingSnapshot> CaptureAsync(string outputDirectory, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Assert.False(Disposed);
            Assert.NotEmpty(outputDirectory);
            Assert.InRange(Events.Count, 0, 1);
            var first = Events.Count == 0;
            Events.Add("capture");
            if (first)
            {
                if (FailBefore)
                {
                    throw new IOException("baseline failed");
                }
                BeforeCancellation.ThrowIfCancellationRequested();
            }
            else
            {
                Assert.True(File.Exists(Artifact));
            }
 
            return Task.FromResult(new RecordingSnapshot(this, outputDirectory, Events.Count));
        }
 
        public void Dispose() => Disposed = true;
    }
 
    private sealed class RecordingComparer : IPipelineOutputComparer<RecordingSnapshot>, IDisposable
    {
        public bool Disposed { get; private set; }
 
        public Task<IReadOnlyList<PipelineOutputChange>> CompareAsync(RecordingSnapshot before, RecordingSnapshot after, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Assert.False(Disposed);
            var sameOwner = ReferenceEquals(before.Owner, after.Owner);
            Assert.Equal(1, before.Capture);
            Assert.Equal(sameOwner ? 2 : 1, after.Capture);
            var snapshotter = before.Owner;
            Assert.False(snapshotter.Disposed);
            Assert.False(after.Owner.Disposed);
            Assert.Equal(sameOwner ? new[] { "capture", "capture" } : new[] { "capture" }, snapshotter.Events);
            Assert.Equal(sameOwner ? new[] { "capture", "capture" } : new[] { "capture" }, after.Owner.Events);
            after.Owner.Events.Add("compare");
 
            return Task.FromResult<IReadOnlyList<PipelineOutputChange>>(snapshotter.ReportUnchanged
                ? [new(PipelineOutputChangeKind.Changed, Path.GetFileName(snapshotter.Artifact))] : []);
        }
 
        public void Dispose() => Disposed = true;
    }
 
    private sealed class DirectorySnapshotter : IPipelineOutputSnapshotter<string>
    {
        public List<string> Directories { get; } = [];
 
        public Task<string> CaptureAsync(string outputDirectory, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Directories.Add(outputDirectory);
 
            return Task.FromResult(outputDirectory);
        }
    }
 
    private sealed class DirectoryComparer(string beforeDirectory, string afterDirectory) : IPipelineOutputComparer<string>
    {
        public int Comparisons { get; private set; }
 
        public Task<IReadOnlyList<PipelineOutputChange>> CompareAsync(string before, string after, CancellationToken ct)
        {
            ct.ThrowIfCancellationRequested();
            Assert.Equal(beforeDirectory, before);
            Assert.Equal(afterDirectory, after);
            Comparisons++;
 
            return Task.FromResult<IReadOnlyList<PipelineOutputChange>>([]);
        }
    }
}
 
#pragma warning restore ASPIREPIPELINES005