File: NativeComposeCSharpTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Native.Core.Tests\Aspire.Hosting.Native.Core.Tests.csproj (Aspire.Hosting.Native.Core.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.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.Native.Authoring;
using Aspire.Hosting.Native.Client;
using Aspire.Hosting.Native.Core.Tests.TestServices;
using NativeIntegrations.DockerCompose;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class NativeComposeCSharpTests
{
    [Fact]
    public async Task NativeModelProducesDeterministicSecretFreeComposeAssetsWithoutDocker()
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var (context, _) = await ExportAsync(client, root.FullName);
            var first = DockerComposeGenerator.Generate(context, "compose");
            var second = DockerComposeGenerator.Generate(context, "compose");
            Assert.True(JsonNode.DeepEquals(first.Compose, second.Compose));
            Assert.True(JsonNode.DeepEquals(first.Inputs, second.Inputs));
            var actualProject = first.Compose["name"]!.GetValue<string>();
            Assert.Equal(DockerComposeGenerator.ProjectName(root.FullName, "Comparison", "compose"), actualProject);
            var build = first.Compose["services"]!["web"]!["build"]!;
            Assert.Equal(Path.Combine(root.FullName, "web"), build["context"]!.GetValue<string>());
            Assert.Equal(Path.Combine(root.FullName, "web", "Dockerfile"), build["dockerfile"]!.GetValue<string>());
            build["context"] = "/app/web";
            build["dockerfile"] = "/app/web/Dockerfile";
            first.Compose["name"] = "comparison-project";
            first.Inputs["projectName"] = "comparison-project";
            var snapshot = new JsonObject { ["compose"] = first.Compose, ["inputs"] = first.Inputs };
            await Verifier.Verify(snapshot.ToJsonString(new() { WriteIndented = true }), "json")
                .UseDirectory("Snapshots");
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Theory]
    [InlineData("default")]
    [InlineData("dashboard")]
    [InlineData("bind-mount")]
    [InlineData("nonsecret-default")]
    [InlineData("ports-disabled")]
    [InlineData("normalization")]
    [InlineData("multiple-environments")]
    [InlineData("excluded-resource")]
    public async Task CSharpAndTypeScriptGenerateTheSamePlanAndResolveNestedExpressions(string scenario)
    {
        var script = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_COMPOSE_TYPESCRIPT");
        if (script is null)
        {
            Assert.Skip("Supply the compiled NativeComposeComparison.mjs to run the cross-language comparison.");
        }
        var root = Directory.CreateTempSubdirectory();
        try
        {
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var (context, _) = await ExportAsync(client, root.FullName);
            var cache = context.Model["resources"]!.AsArray().Single(resource => resource!["name"]!.GetValue<string>() == "cache")!;
            var web = context.Model["resources"]!.AsArray().Single(resource => resource!["name"]!.GetValue<string>() == "web")!;
            var target = context.Model["resources"]!.AsArray().Single(resource => resource!["name"]!.GetValue<string>() == "compose")!;
            switch (scenario)
            {
                case "dashboard":
                    target["configuration"]!["publish.dockerComposeEnvironment"] =
                        """{"defaultNetworkName":"existing-network","dashboardEnabled":true,"dashboardHostPort":null,"dashboardForwardedHeaders":true}""";
                    break;
                case "bind-mount":
                    cache["mounts"]!.AsArray().Add(new JsonObject
                    {
                        ["kind"] = "bind", ["source"] = "/deployment-host/with $ spaces", ["target"] = "/config", ["readOnly"] = true
                    });
                    break;
                case "nonsecret-default":
                    context.Model["parameters"]!.AsArray().Add("message");
                    context.Model["parameterDefaults"]!.AsObject()["message"] = "literal $ configuration";
                    web["environment"]!.AsObject()["MESSAGE"] = new JsonObject { ["kind"] = "parameter", ["name"] = "message" };
                    break;
                case "ports-disabled":
                    var options = JsonNode.Parse(web["configuration"]!["publish.dockerCompose"]!.GetValue<string>())!;
                    options["ports"] = new JsonArray();
                    web["configuration"]!["publish.dockerCompose"] = options.ToJsonString();
                    break;
                case "normalization":
                    cache["name"] = "Cache.With Space";
                    cache["mounts"]![0]!["source"] = "RetainedData";
                    web["dependencies"]![0] = "Cache.With Space";
                    web["environment"]!["ConnectionStrings__cache"]!["parts"]!.AsArray()
                        .Single(part => part!["kind"]!.GetValue<string>() == "endpoint")!["resource"] = "Cache.With Space";
                    break;
                case "multiple-environments":
                    var other = target.DeepClone();
                    other["name"] = "other";
                    context.Model["resources"]!.AsArray().Add(other);
                    var isolated = cache.DeepClone();
                    isolated["name"] = "isolated";
                    isolated["computeEnvironment"] = "other";
                    isolated["configuration"]!["publish.computeEnvironment"] = "other";
                    context.Model["resources"]!.AsArray().Add(isolated);
                    break;
                case "excluded-resource":
                    context.Model["resources"]!.AsArray().Add(new JsonObject
                    {
                        ["name"] = "opaque", ["type"] = "example/opaque", ["kind"] = "resource",
                        ["configuration"] = new JsonObject { ["publish.dockerCompose"] = """{"exclude":true}""" }
                    });
                    break;
            }
            var environment = new Dictionary<string, string?>
            {
                ["ASPIRE_PARAMETER_CACHE_PASSWORD"] = "quoted ' + / : % $ \" password",
                ["ASPIRE_PARAMETER_COMPOSE_DASHBOARD_TOKEN"] = "explicit-browser-token",
                ["ASPIRE_PARAMETER_COMPOSE_DASHBOARD_OTLP_KEY"] = "explicit-otlp-key"
            };
            var plan = DockerComposeGenerator.Generate(context, "compose");
            var request = new JsonObject
            {
                ["context"] = new JsonObject
                {
                    ["model"] = context.Model.DeepClone(), ["applicationDirectory"] = context.ApplicationDirectory,
                    ["environmentName"] = context.EnvironmentName, ["outputPath"] = context.OutputPath
                },
                ["name"] = "compose",
                ["environment"] = new JsonObject(environment.Select(pair => KeyValuePair.Create(pair.Key, (JsonNode?)JsonValue.Create(pair.Value))))
            };
            var requestPath = Path.Combine(root.FullName, "comparison.json");
            await File.WriteAllTextAsync(requestPath, request.ToJsonString(), TestContext.Current.CancellationToken);
            var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("ASPIRE_NATIVE_SYMBOLIC_TEST_NODE") ?? "node")
            {
                UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true
            };
            start.ArgumentList.Add(script);
            start.ArgumentList.Add(requestPath);
            using var process = Process.Start(start)!;
            var stdout = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken);
            var stderr = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken);
            await process.WaitForExitAsync(TestContext.Current.CancellationToken);
            Assert.True(process.ExitCode == 0, await stderr);
            var typescript = JsonNode.Parse(await stdout)!.AsObject();
            Assert.True(JsonNode.DeepEquals(plan.Compose, typescript["compose"]), "C# and TypeScript Compose documents differ.");
            Assert.True(JsonNode.DeepEquals(plan.Inputs, typescript["inputs"]), "C# and TypeScript input contracts differ.");
            Assert.True(JsonNode.DeepEquals(JsonSerializer.SerializeToNode(plan.Services, new JsonSerializerOptions(JsonSerializerDefaults.Web)
            {
                DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
            }), typescript["services"]), "C# and TypeScript expected deployment states differ.");
            var resolved = DockerComposeGenerator.ResolveInputs(plan, context.Model, environment);
            Assert.Equal(typescript["resolved"]!.AsObject().OrderBy(pair => pair.Key, StringComparer.Ordinal)
                .Select(pair => KeyValuePair.Create(pair.Key, pair.Value!.GetValue<string>())), resolved.OrderBy(pair => pair.Key, StringComparer.Ordinal));
            Assert.Equal("redis://:quoted%20'%20%2B%20%2F%20%3A%20%25%20%24%20%22%20password@cache:6379",
                "redis://:" + resolved["ASPIRE_COMPOSE_EXPR_0001"] + "@cache:6379");
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Fact]
    public async Task FluentCallbacksPublishAndNativePipelineSelectsCustomTarget()
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            Directory.CreateDirectory(Path.Combine(root.FullName, "web"));
            await File.WriteAllTextAsync(Path.Combine(root.FullName, "web", "Dockerfile"), "FROM node:24-alpine\n");
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var runner = new ComposeTestRunner();
            var builder = ComposeTestApplication.Create(root.FullName, environment =>
                environment.WithRunner(runner.RunAsync).ConfigureComposeFile(file =>
                {
                    file.AddVolume("helper-data", labels: new Dictionary<string, string> { ["owned"] = "comparison" });
                    file.GetService("web").PullPolicy = "missing";
                    file.AddConfig("settings", "$LITERAL");
                    file.AddSecret("external-secret");
                }));
            builder.AddContainer("helper", "alpine:3.22")
                .PublishAsDockerComposeService((target, service) =>
                {
                    Assert.Equal("compose", target.Parent.Resource.Name);
                    service.Restart = "unless-stopped";
                    service.SetLabel("literal", "$HOME");
                    service.AddVolume("helper-data", "/helper");
                });
            var target = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
            var step = builder.Pipeline.AddStep("verify-publish", async context =>
            {
                Assert.True(File.Exists(Path.Combine(context.PublishingContext.OutputPath, "compose.yaml")));
                await context.AddSummaryAsync("Comparison", "Artifacts generated");
                target.SetResult();
            });
            step.DependsOn.Add("publish");
            var context = await builder.PublishAsync(client, "published", "Comparison", "verify-publish", TestContext.Current.CancellationToken);
            await target.Task;
            var document = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(context.OutputPath, "compose.yaml")))!;
            Assert.Empty(runner.Calls);
            await Verify(document.ToJsonString(new() { WriteIndented = true }), "json")
                .UseDirectory("Snapshots")
                .AddScrubber(text => text
                    .Replace(JsonEncodedText.Encode(Path.Combine(root.FullName, "web", "Dockerfile")).ToString(), "/app/web/Dockerfile")
                    .Replace(JsonEncodedText.Encode(Path.Combine(root.FullName, "web")).ToString(), "/app/web")
                    .Replace(DockerComposeGenerator.ProjectName(root.FullName, "Comparison", "compose"), "comparison-project"));
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Fact]
    public async Task DeployUsesPrivateEnvironmentAndVerifiesPublishedBindings()
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            Directory.CreateDirectory(Path.Combine(root.FullName, "web"));
            await File.WriteAllTextAsync(Path.Combine(root.FullName, "web", "Dockerfile"), "FROM node:24-alpine\n");
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var (context, _) = await ExportAsync(client, root.FullName);
            var runner = new ComposeTestRunner();
            var target = new DockerComposeEnvironment("compose", new()
            {
                Runner = runner.RunAsync, ParameterEnvironment = new Dictionary<string, string?> { ["ASPIRE_PARAMETER_CACHE_PASSWORD"] = "explicit-secret" }
            });
            await target.PublishAsync(context);
            Assert.Empty(runner.Calls);
            await target.DeployAsync(context);
            Assert.All(runner.Calls, call =>
            {
                Assert.Equal("docker", call.Executable);
                Assert.Equal("1", call.Options.Environment["COMPOSE_DISABLE_ENV_FILE"]);
                Assert.Equal("explicit-secret", call.Options.Environment["ASPIRE_PARAMETER_CACHE_PASSWORD"]);
            });
            Assert.All(runner.Calls.Skip(2), call =>
                Assert.Equal(OperatingSystem.IsWindows() ? "NUL" : "/dev/null", call.Arguments[4]));
            var state = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(context.OutputPath, "deployment-state.json")))!;
            var snapshot = new
            {
                state,
                calls = runner.Calls.Select(call => new
                {
                    executable = call.Executable,
                    arguments = call.Arguments.Select(argument => NormalizePath(argument == "NUL" ? "/dev/null" : argument)),
                    workingDirectory = NormalizePath(call.Options.WorkingDirectory),
                    timeout = call.Options.Timeout
                })
            };
            await Verify(JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }), "json")
                .UseDirectory("Snapshots")
                .AddScrubber(text => text.Replace(
                    DockerComposeGenerator.ProjectName(root.FullName, "Comparison", "compose"), "comparison-project"));
            await target.DeployAsync(context);
            Assert.Equal(10, runner.Calls.Count);
 
            string NormalizePath(string value) => value.Replace(root.FullName, "/app", StringComparison.Ordinal)
                .Replace(Path.DirectorySeparatorChar, '/');
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Fact]
    public async Task MissingInputsAndStaleArtifactsFailBeforeAnyDockerCall()
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            Directory.CreateDirectory(Path.Combine(root.FullName, "web"));
            await File.WriteAllTextAsync(Path.Combine(root.FullName, "web", "Dockerfile"), "FROM node:24-alpine\n");
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var (context, _) = await ExportAsync(client, root.FullName);
            var runner = new ComposeTestRunner();
            var target = new DockerComposeEnvironment("compose", new() { Runner = runner.RunAsync, ParameterEnvironment = new Dictionary<string, string?>() });
            await Assert.ThrowsAsync<InvalidOperationException>(() => target.DeployAsync(context));
            await target.PublishAsync(context);
            var missing = await Assert.ThrowsAsync<InvalidOperationException>(() => target.DeployAsync(context));
            Assert.Equal("Set required deployment input ASPIRE_PARAMETER_CACHE_PASSWORD explicitly.", missing.Message);
            Assert.Empty(runner.Calls);
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Fact]
    public async Task DockerFailuresRedactRawJsonAndUriEncodedSecrets()
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            Directory.CreateDirectory(Path.Combine(root.FullName, "web"));
            await File.WriteAllTextAsync(Path.Combine(root.FullName, "web", "Dockerfile"), "FROM node:24-alpine\n");
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var (context, _) = await ExportAsync(client, root.FullName);
            var secret = "secret + \" / value";
            var runner = new ComposeTestRunner { ApplyFailure = secret + " " + Uri.EscapeDataString(secret) + " " + JsonSerializer.Serialize(secret)[1..^1] };
            var target = new DockerComposeEnvironment("compose", new()
            {
                Runner = runner.RunAsync, ParameterEnvironment = new Dictionary<string, string?> { ["ASPIRE_PARAMETER_CACHE_PASSWORD"] = secret }
            });
            await target.PublishAsync(context);
            var error = await Assert.ThrowsAsync<InvalidOperationException>(() => target.DeployAsync(context));
            Assert.Equal("Docker Compose apply failed (exit 1): \n[REDACTED] [REDACTED] [REDACTED]", error.Message);
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Fact]
    public async Task NativePipelineRunsReadyBranchesConcurrentlyAndSuppressesFailedDependents()
    {
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await ConnectAsync(host);
        var pipeline = new NativePipeline();
        var rendezvous = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var started = 0;
        foreach (var name in new[] { "left", "right" })
        {
            var step = pipeline.AddStep(name, async context =>
            {
                if (Interlocked.Increment(ref started) == 2)
                {
                    rendezvous.SetResult();
                }
                await rendezvous.Task.WaitAsync(TimeSpan.FromSeconds(5), context.CancellationToken);
                if (name == "left")
                {
                    throw new InvalidOperationException("policy rejected");
                }
            });
            step.RequiredBy.Add("publish");
        }
        var dependentRan = false;
        var dependent = pipeline.AddStep("dependent", _ => { dependentRan = true; return Task.CompletedTask; });
        dependent.DependsOn.Add("left");
        dependent.RequiredBy.Add("publish");
        var failure = await Assert.ThrowsAsync<InvalidOperationException>(() => pipeline.ExecuteAsync(client.Server,
            new(new JsonObject(), AppContext.BaseDirectory, AppContext.BaseDirectory, "Comparison", TestContext.Current.CancellationToken), "publish"));
        Assert.Equal("policy rejected", failure.Message);
        Assert.Equal(2, started);
        Assert.False(dependentRan);
    }
 
    [Fact]
    public async Task RunModeLeavesComposeEnvironmentOutOfNativeModel()
    {
        var builder = DistributedApplication.CreateBuilder([]);
        builder.AddDockerComposeEnvironment("compose").ConfigureComposeFile(_ => throw new InvalidOperationException("Publish-only callback executed."));
        builder.AddContainer("cache", "redis:7.4-alpine").PublishAsDockerComposeService(new DockerComposeServiceOptions { Image = "publish:only" });
        await using var host = await NativeClientTestHost.StartAsync();
        await using var client = await ConnectAsync(host);
        var session = await client.Server.OpenApplicationAsync();
        var composition = await session.StartGenerationAsync();
        await builder.MaterializeAsync(composition, TestContext.Current.CancellationToken);
        var model = JsonNode.Parse(await composition.ExportPublishModelAsync())!;
        Assert.Equal(["cache"], model["resources"]!.AsArray().Select(resource => resource!["name"]!.GetValue<string>()));
        Assert.False(builder.Pipeline.HasCustomizations);
        await session.RetireGenerationAsync(composition);
    }
 
    [Fact]
    public async Task BeforeStartPipelineRunsBeforeNativeWorkloadPreparation()
    {
        var executor = new WorkloadTestExecutor();
        executor.ReleaseCleanup();
        await using var host = await NativeClientTestHost.StartWithExecutorAsync(executor, new());
        var builder = DistributedApplication.CreateBuilder([$"--native-endpoint={host.Endpoint}", $"--ASPIRE_NATIVE_RPC_TOKEN={host.Token}"]);
        builder.AddContainer("cache", "redis:7.4-alpine");
        var invoked = false;
        builder.Pipeline.AddStep("startup-policy", context =>
        {
            Assert.True(context.PublishingContext.IsRunMode);
            Assert.Empty(executor.Prepared);
            invoked = true;
 
            return Task.CompletedTask;
        }).RequiredBy.Add("before-start");
        await using var app = builder.Build();
        await app.StartAsync(TestContext.Current.CancellationToken);
        Assert.True(invoked);
        Assert.Single(executor.Prepared);
    }
 
    [Theory]
    [InlineData("""{"build":"not-an-object"}""")]
    [InlineData("""{"healthcheck":[]}""")]
    [InlineData("""{"image":null}""")]
    [InlineData("""{"ports":[{"endpoint":"http","published":70000}]}""")]
    [InlineData("""{"healthcheck":{"test":["CMD-SHELL","one","two"]}}""")]
    public async Task MalformedDeploymentConfigurationFailsExplicitly(string options)
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var (context, _) = await ExportAsync(client, root.FullName);
            var resource = context.Model["resources"]!.AsArray().Single(resource => resource!["name"]!.GetValue<string>() == "web")!;
            resource["configuration"]!["publish.dockerCompose"] = options;
            var failure = Record.Exception(() => DockerComposeGenerator.Generate(context, "compose"));
            Assert.True(failure is ArgumentException or InvalidOperationException);
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Fact]
    public async Task NativeComputeAssignmentsRejectCrossEnvironmentReferences()
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var (context, _) = await ExportAsync(client, root.FullName);
            var resources = context.Model["resources"]!.AsArray();
            var environment = resources.Single(resource => resource!["name"]!.GetValue<string>() == "compose")!.DeepClone();
            environment["name"] = "other";
            resources.Add(environment);
            var cache = resources.Single(resource => resource!["name"]!.GetValue<string>() == "cache")!;
            cache["computeEnvironment"] = "other";
            cache["configuration"]!["publish.computeEnvironment"] = "other";
            var failure = Assert.Throws<InvalidOperationException>(() => DockerComposeGenerator.Generate(context, "compose"));
            Assert.Contains("across compute environments", failure.Message, StringComparison.Ordinal);
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Theory]
    [InlineData("{}")]
    [InlineData("null")]
    [InlineData("""{"defaultNetworkName":null,"dashboardEnabled":null,"dashboardHostPort":null,"dashboardForwardedHeaders":false}""")]
    public async Task MalformedEnvironmentConfigurationFailsExplicitly(string configuration)
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            await using var host = await NativeClientTestHost.StartAsync();
            await using var client = await ConnectAsync(host);
            var (context, _) = await ExportAsync(client, root.FullName);
            var environment = context.Model["resources"]!.AsArray().Single(resource => resource!["name"]!.GetValue<string>() == "compose")!;
            environment["configuration"]!["publish.dockerComposeEnvironment"] = configuration;
            Assert.Throws<ArgumentException>(() => DockerComposeGenerator.Generate(context, "compose"));
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Theory]
    [InlineData("setInterval(() => {}, 1000)", false)]
    [InlineData("process.stdout.write('x'.repeat(1024 * 1024 + 1)); setInterval(() => {}, 1000)", true)]
    public async Task ToolExecutionBoundsTimeoutAndCombinedOutput(string script, bool exceedsOutput)
    {
        var node = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_SYMBOLIC_TEST_NODE");
        if (node is null)
        {
            Assert.Skip("Supply Node.js for real process timeout and output-bound tests.");
        }
        var root = Directory.CreateTempSubdirectory();
        try
        {
            var failure = await Assert.ThrowsAsync<InvalidOperationException>(() => DockerCommand.RunAsync(node, ["-e", script],
                new(root.FullName, new Dictionary<string, string>(), exceedsOutput ? TimeSpan.FromSeconds(30) : TimeSpan.FromMilliseconds(200),
                    TestContext.Current.CancellationToken)).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
            Assert.Equal("Docker command was cancelled, timed out, or exceeded the bounded output limit.", failure.Message);
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    [Fact]
    public async Task ToolExecutionPreservesCallerCancellation()
    {
        var node = Environment.GetEnvironmentVariable("ASPIRE_NATIVE_SYMBOLIC_TEST_NODE");
        if (node is null)
        {
            Assert.Skip("Supply Node.js for real process cancellation tests.");
        }
        var root = Directory.CreateTempSubdirectory();
        try
        {
            using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
            var execution = DockerCommand.RunAsync(node, ["-e", "setInterval(() => {}, 1000)"],
                new(root.FullName, new Dictionary<string, string>(), TimeSpan.FromSeconds(30), cancellation.Token));
            await cancellation.CancelAsync();
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => execution.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
        }
        finally
        {
            root.Delete(true);
        }
    }
 
    private static Task<NativeClient> ConnectAsync(NativeClientTestHost host) =>
        NativeClient.ConnectAsync(host.Endpoint, host.Token, TimeSpan.FromSeconds(10), TimeProvider.System, TestContext.Current.CancellationToken);
 
    private static async Task<(PublishingContext Context, DistributedApplicationBuilder Builder)> ExportAsync(NativeClient client, string root)
    {
        var builder = ComposeTestApplication.Create(root);
        var session = await client.Server.OpenApplicationAsync();
        var composition = await session.StartGenerationAsync();
        await builder.MaterializeAsync(composition, TestContext.Current.CancellationToken);
        var model = JsonNode.Parse(await composition.ExportPublishModelAsync())!.AsObject();
        await session.RetireGenerationAsync(composition);
 
        return (new(model, Path.Combine(root, "published"), root, "Comparison", TestContext.Current.CancellationToken), builder);
    }
}