File: WithProcessCommandTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Diagnostics;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using AuxiliaryBackchannelRpcTarget = Aspire.Hosting.Backchannel.AuxiliaryBackchannelRpcTarget;
using ExecuteResourceCommandRequest = Aspire.Hosting.Backchannel.ExecuteResourceCommandRequest;
 
namespace Aspire.Hosting.Tests;
 
#pragma warning disable ASPIREPROCESSCOMMAND001 // Process command APIs are experimental.
#pragma warning disable CS0618 // Tests intentionally cover the deprecated TypeScript withProcessCommandFactory export.
 
[Trait("Partition", "6")]
public class WithProcessCommandTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public void ProcessCommandOptions_Default_ReturnsIndependentInstances()
    {
        var options = ProcessCommandOptions.Default;
 
        options.Description = "Mutated description";
        options.MaxOutputLineCount = 1;
        options.DisplayImmediately = false;
        options.SuccessExitCodes = [0, 5];
 
        var defaultOptions = ProcessCommandOptions.Default;
 
        Assert.Null(defaultOptions.Description);
        Assert.Equal(50, defaultOptions.MaxOutputLineCount);
        Assert.True(defaultOptions.DisplayImmediately);
        Assert.Equal([0], defaultOptions.SuccessExitCodes);
    }
 
    [Fact]
    public void WithProcessCommand_AddsResourceCommandAnnotation_WithCustomValues()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var resourceBuilder = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "my-command",
                "Run command",
                "dotnet",
                ["--version"],
                new ProcessCommandOptions
                {
                    Description = "Command description",
                    ConfirmationMessage = "Are you sure?",
                    IconName = "Command",
                    IconVariant = IconVariant.Filled,
                    IsHighlighted = true,
                    Arguments =
                    [
                        new InteractionInput
                        {
                            Name = "target",
                            InputType = InputType.Text
                        }
                    ]
                });
 
        var command = resourceBuilder.Resource.Annotations.OfType<ResourceCommandAnnotation>().Single();
 
        Assert.Equal("my-command", command.Name);
        Assert.Equal("Run command", command.DisplayName);
        Assert.Equal("Command description", command.DisplayDescription);
        Assert.Equal("Are you sure?", command.ConfirmationMessage);
        Assert.Equal("Command", command.IconName);
        Assert.Equal(IconVariant.Filled, command.IconVariant);
        Assert.True(command.IsHighlighted);
        var argument = Assert.Single(command.Arguments);
        Assert.Equal("target", argument.Name);
    }
 
    [Fact]
    public async Task WithProcessCommandExport_AddsResourceCommandAnnotationAndRunsProcess()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["export-line"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandExport(
                "export-command",
                "Run export command",
                new ProcessCommandExportOptions
                {
                    ExecutablePath = "export-command-executable",
                    Arguments = ["--export"],
                    CommandOptions = new CommandOptions
                    {
                        Description = "Export command description",
                        ConfirmationMessage = "Run export command?",
                        IconName = "Command",
                        IconVariant = IconVariant.Filled,
                        IsHighlighted = true
                    },
                    MaxOutputLineCount = 5,
                    DisplayImmediately = false
                });
 
        var command = resource.Resource.Annotations.OfType<ResourceCommandAnnotation>().Single();
 
        Assert.Equal("export-command", command.Name);
        Assert.Equal("Run export command", command.DisplayName);
        Assert.Equal("Export command description", command.DisplayDescription);
        Assert.Equal("Run export command?", command.ConfirmationMessage);
        Assert.Equal("Command", command.IconName);
        Assert.Equal(IconVariant.Filled, command.IconVariant);
        Assert.True(command.IsHighlighted);
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "export-command").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Equal(CommandResultFormat.Text, result.Data?.Format);
        Assert.False(result.Data?.DisplayImmediately);
        Assert.Contains("export-line", result.Data?.Value);
 
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("export-command-executable", processSpec.ExecutablePath);
        Assert.Equal(["--export"], processSpec.ArgumentList);
        Assert.Equal(5, processSpec.RetainedOutputLineCount);
        Assert.True(processSpec.ResolveExecutablePath);
        Assert.False(processSpec.ThrowOnNonZeroReturnCode);
    }
 
    [Fact]
    public async Task WithProcessCommandExport_CreateProcessSpecReceivesExecutionContextAndArguments()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["options-callback-line"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        ExecuteCommandContext? capturedContext = null;
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandExport(
                "options-callback-command",
                "Run options callback command",
                new ProcessCommandExportOptions
                {
                    CreateProcessSpec = context =>
                    {
                        capturedContext = context;
                        var message = context.Arguments.GetString("message") ?? string.Empty;
 
                        return Task.FromResult(new ProcessCommandSpecExportData
                        {
                            ExecutablePath = "options-callback-executable",
                            Arguments = ["--message", message]
                        });
                    },
                    CommandOptions = new CommandOptions
                    {
                        Arguments =
                        [
                            new InteractionInput
                            {
                                Name = "message",
                                InputType = InputType.Text
                            }
                        ]
                    }
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var arguments = new InteractionInputCollection(
        [
            new InteractionInput
            {
                Name = "message",
                InputType = InputType.Text,
                Value = "hello-from-options-callback"
            }
        ]);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "options-callback-command", arguments).DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Contains("options-callback-line", result.Data?.Value);
        Assert.NotNull(capturedContext);
        Assert.Equal("hello-from-options-callback", capturedContext.Arguments.GetString("message"));
 
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("options-callback-executable", processSpec.ExecutablePath);
        Assert.Equal(["--message", "hello-from-options-callback"], processSpec.ArgumentList);
    }
 
    [Fact]
    public async Task WithProcessCommandFactoryExport_ProcessFactoryReceivesExecutionContextAndArguments()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["factory-line"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        ExecuteCommandContext? capturedContext = null;
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandFactoryExport(
                "factory-command",
                "Run factory command",
                context =>
                {
                    capturedContext = context;
                    var message = context.Arguments.GetString("message") ?? string.Empty;
 
                    return Task.FromResult(new ProcessCommandSpecExportData
                    {
                        ExecutablePath = "factory-command-executable",
                        Arguments = ["--message", message],
                        WorkingDirectory = "/test/factory-working-directory",
                        EnvironmentVariables = new Dictionary<string, string>
                        {
                            ["PROCESS_COMMAND_FACTORY_VALUE"] = message
                        },
                        InheritEnvironmentVariables = false,
                        StandardInputContent = "from-factory-stdin",
                        KillEntireProcessTree = false
                    });
                },
                new ProcessCommandResultExportOptions
                {
                    CommandOptions = new CommandOptions
                    {
                        Description = "Factory command description",
                        Arguments =
                        [
                            new InteractionInput
                            {
                                Name = "message",
                                InputType = InputType.Text
                            }
                        ]
                    },
                    MaxOutputLineCount = 7,
                    DisplayImmediately = false
                });
 
        var command = resource.Resource.Annotations.OfType<ResourceCommandAnnotation>().Single();
        Assert.Equal("Factory command description", command.DisplayDescription);
        var argument = Assert.Single(command.Arguments);
        Assert.Equal("message", argument.Name);
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var arguments = new InteractionInputCollection(
        [
            new InteractionInput
            {
                Name = "message",
                InputType = InputType.Text,
                Value = "hello-from-factory"
            }
        ]);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "factory-command", arguments).DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Equal(CommandResultFormat.Text, result.Data?.Format);
        Assert.False(result.Data?.DisplayImmediately);
        Assert.Contains("factory-line", result.Data?.Value);
        Assert.NotNull(capturedContext);
        Assert.Equal(resource.Resource.Name, capturedContext.ResourceName);
        Assert.Equal("hello-from-factory", capturedContext.Arguments.GetString("message"));
 
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("factory-command-executable", processSpec.ExecutablePath);
        Assert.Equal(["--message", "hello-from-factory"], processSpec.ArgumentList);
        Assert.Equal("/test/factory-working-directory", processSpec.WorkingDirectory);
        Assert.Equal("hello-from-factory", processSpec.EnvironmentVariables["PROCESS_COMMAND_FACTORY_VALUE"]);
        Assert.False(processSpec.InheritEnv);
        Assert.Equal("from-factory-stdin", processSpec.StandardInputContent);
        Assert.False(processSpec.KillEntireProcessTree);
        Assert.Equal(7, processSpec.RetainedOutputLineCount);
    }
 
    [Fact]
    public async Task WithProcessCommandFactoryExport_SuccessExitCodes_TreatsConfiguredExitCodeAsSuccess()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(exitCode: 17, output: ["factory-accepted-output"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandFactoryExport(
                "factory-accepted-exit-code",
                "Factory accepted exit code",
                _ => Task.FromResult(new ProcessCommandSpecExportData
                {
                    ExecutablePath = "factory-executable"
                }),
                new ProcessCommandResultExportOptions
                {
                    SuccessExitCodes = [0, 17]
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "factory-accepted-exit-code").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Contains("factory-accepted-output", result.Data?.Value);
    }
 
    [Fact]
    public async Task WithProcessCommandFactoryExport_NullProcessSpecFactoryResult_ReturnsFailure()
    {
        var processRunner = new TestProcessRunner();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandFactoryExport(
                "null-process-spec",
                "Null process spec",
                _ => Task.FromResult<ProcessCommandSpecExportData>(null!));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "null-process-spec").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.Contains("factory returned null", result.Message);
        Assert.Empty(processRunner.ProcessSpecs);
    }
 
    [Fact]
    public async Task WithProcessCommand_ProcessFactoryReceivesExecutionContextAndArguments()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["hello-from-argument"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        ExecuteCommandContext? capturedContext = null;
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "echo-argument",
                "Echo argument",
                context =>
                {
                    capturedContext = context;
                    var message = context.Arguments.GetString("message");
                    return CreateProcessCommandSpec(arguments: [message ?? string.Empty]);
                },
                new ProcessCommandOptions
                {
                    Arguments =
                    [
                        new InteractionInput
                        {
                            Name = "message",
                            InputType = InputType.Text
                        }
                    ]
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var arguments = new InteractionInputCollection(
        [
            new InteractionInput
            {
                Name = "message",
                InputType = InputType.Text,
                Value = "hello-from-argument"
            }
        ]);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "echo-argument", arguments).DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Equal(CommandResultFormat.Text, result.Data?.Format);
        Assert.Contains("hello-from-argument", result.Data?.Value);
        Assert.NotNull(capturedContext);
        Assert.Equal(resource.Resource.Name, capturedContext.ResourceName);
        Assert.NotNull(capturedContext.Services);
        Assert.Equal("hello-from-argument", capturedContext.Arguments.GetString("message"));
        Assert.NotNull(capturedContext.Logger);
 
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal(["hello-from-argument"], processSpec.ArgumentList);
    }
 
    [Fact]
    public async Task WithProcessCommand_BackchannelNamedArgumentsFlowToProcessFactoryAndResultContext()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["hello-from-backchannel"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        ProcessCommandResultContext? capturedResultContext = null;
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "echo-argument",
                "Echo argument",
                context =>
                {
                    var message = context.Arguments.GetString("message");
                    return CreateProcessCommandSpec(arguments: ["--message", message ?? string.Empty]);
                },
                new ProcessCommandOptions
                {
                    Arguments =
                    [
                        new InteractionInput
                        {
                            Name = "message",
                            InputType = InputType.Text
                        }
                    ],
                    GetCommandResult = resultContext =>
                    {
                        capturedResultContext = resultContext;
                        return Task.FromResult(CommandResults.Success("received argument", resultContext.Arguments.GetString("message")!));
                    }
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var target = new AuxiliaryBackchannelRpcTarget(
            NullLogger<AuxiliaryBackchannelRpcTarget>.Instance,
            app.Services.GetRequiredService<IConfiguration>(),
            app.Services.GetRequiredService<ProfilingTelemetry>(),
            app.Services);
 
        var response = await target.ExecuteResourceCommandAsync(new ExecuteResourceCommandRequest
        {
            ResourceName = resource.Resource.Name,
            CommandName = "echo-argument",
            Arguments = JsonSerializer.SerializeToNode(new
            {
                message = "hello-from-backchannel"
            })
        }).DefaultTimeout();
 
        Assert.True(response.Success, response.Message);
        Assert.Equal("received argument", response.Message);
        Assert.Equal("hello-from-backchannel", response.Value?.Value);
 
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal(["--message", "hello-from-backchannel"], processSpec.ArgumentList);
 
        Assert.NotNull(capturedResultContext);
        Assert.Equal("hello-from-backchannel", capturedResultContext.Arguments.GetString("message"));
    }
 
    [Fact]
    public async Task WithProcessCommand_AwaitsAsyncProcessFactory()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["async-factory-output"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
        var factoryAwaited = false;
 
        async ValueTask<ProcessCommandSpec> CreateSpecAsync(ExecuteCommandContext _)
        {
            await Task.Yield();
            factoryAwaited = true;
 
            return CreateProcessCommandSpec(arguments: ["from-async-factory"]);
        }
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "async-factory",
                "Async factory",
                CreateSpecAsync);
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "async-factory").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.True(factoryAwaited);
        Assert.Contains("async-factory-output", result.Data?.Value);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal(["from-async-factory"], processSpec.ArgumentList);
    }
 
    [Fact]
    public async Task WithProcessCommand_ExecutesFreshProcessForEachInvocation()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["first-output"]);
        processRunner.EnqueueResult(exitCode: 3, output: ["second-output"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
        var invocationCount = 0;
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "repeat",
                "Repeat",
                _ =>
                {
                    invocationCount++;
 
                    return CreateProcessCommandSpec(
                        executablePath: $"test-command-{invocationCount}",
                        arguments: [$"arg-{invocationCount}"]);
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var firstResult = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "repeat").DefaultTimeout();
        var secondResult = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "repeat").DefaultTimeout();
 
        Assert.True(firstResult.Success);
        Assert.Contains("first-output", firstResult.Data?.Value);
        Assert.False(secondResult.Success);
        Assert.Contains("exited with code 3", secondResult.Message);
        Assert.Contains("configured success exit codes [0]", secondResult.Message);
        Assert.Contains("second-output", secondResult.Data?.Value);
 
        Assert.Collection(
            processRunner.ProcessSpecs,
            processSpec =>
            {
                Assert.Equal("test-command-1", processSpec.ExecutablePath);
                Assert.Equal(["arg-1"], processSpec.ArgumentList);
            },
            processSpec =>
            {
                Assert.Equal("test-command-2", processSpec.ExecutablePath);
                Assert.Equal(["arg-2"], processSpec.ArgumentList);
            });
    }
 
    [Fact]
    public async Task WithProcessCommand_ReturnsBoundedOutput()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["line-3", "line-4", "line-5"], totalOutputLineCount: 5);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "many-lines",
                "Many lines",
                _ => CreateProcessCommandSpec(),
                new ProcessCommandOptions
                {
                    MaxOutputLineCount = 3
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "many-lines").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.NotNull(result.Data);
        Assert.Equal(CommandResultFormat.Text, result.Data.Format);
        Assert.True(result.Data.DisplayImmediately);
        Assert.Contains("Command output truncated: showing last 3 of 5 lines.", result.Data.Value);
        Assert.DoesNotContain("line-1", result.Data.Value);
        Assert.DoesNotContain("line-2", result.Data.Value);
        Assert.Contains("line-3", result.Data.Value);
        Assert.Contains("line-5", result.Data.Value);
    }
 
    [Fact]
    public async Task WithProcessCommand_DisplayImmediately_CanBeDisabled()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["display-immediately-output"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "display-immediately",
                "Display immediately",
                _ => CreateProcessCommandSpec(),
                new ProcessCommandOptions
                {
                    DisplayImmediately = false
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "display-immediately").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.NotNull(result.Data);
        Assert.False(result.Data.DisplayImmediately);
        Assert.Contains("display-immediately-output", result.Data.Value);
    }
 
    [Fact]
    public async Task WithProcessCommand_SuccessWithoutOutput_ReturnsNoResultData()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "no-output",
                "No output",
                _ => CreateProcessCommandSpec());
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "no-output").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Null(result.Data);
    }
 
    [Fact]
    public async Task WithProcessCommand_NonZeroExitWithoutOutput_ReturnsFailureWithoutResultData()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(exitCode: 9);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "fail-no-output",
                "Fail without output",
                _ => CreateProcessCommandSpec());
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "fail-no-output").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.Contains("exited with code 9", result.Message);
        Assert.Contains("configured success exit codes [0]", result.Message);
        Assert.Null(result.Data);
    }
 
    [Fact]
    public async Task WithProcessCommand_SuccessExitCodes_TreatsConfiguredExitCodeAsSuccess()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(exitCode: 5, output: ["accepted-output"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "accepted-exit-code",
                "Accepted exit code",
                _ => CreateProcessCommandSpec(),
                new ProcessCommandOptions
                {
                    SuccessExitCodes = [0, 5]
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "accepted-exit-code").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Contains("accepted-output", result.Data?.Value);
    }
 
    [Fact]
    public void WithProcessCommand_EmptySuccessExitCodes_ThrowsWhenConfiguringOptions()
    {
        var exception = Assert.Throws<ArgumentException>(() =>
            new ProcessCommandOptions
            {
                SuccessExitCodes = []
            });
 
        Assert.Contains("At least one process command success exit code must be specified.", exception.Message);
    }
 
    [Fact]
    public async Task WithProcessCommand_NonZeroExitWithOutput_HonorsDisplayImmediatelyOption()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(exitCode: 2, output: ["failure-output"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "failure-display-immediately",
                "Failure display immediately",
                _ => CreateProcessCommandSpec(),
                new ProcessCommandOptions
                {
                    DisplayImmediately = false
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "failure-display-immediately").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.NotNull(result.Data);
        Assert.False(result.Data.DisplayImmediately);
        Assert.Contains("failure-output", result.Data.Value);
    }
 
    [Fact]
    public async Task WithProcessCommand_StreamsStdoutAndStderrToResourceLoggerAndResult()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["stdout-line"], error: ["stderr-line"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "log-output",
                "Log output",
                _ => CreateProcessCommandSpec());
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "log-output").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Contains("stdout-line", result.Data?.Value);
        Assert.Contains("stderr-line", result.Data?.Value);
 
        var loggerService = app.Services.GetRequiredService<ResourceLoggerService>();
        var logs = await ConsoleLoggingTestHelpers.WatchForLogsAsync(loggerService, 4, resource.Resource).DefaultTimeout();
        var logContents = logs.Select(log => log.Content).ToArray();
 
        Assert.Contains(logContents, log => log.Contains("(stdout): stdout-line", StringComparison.Ordinal));
        Assert.Contains(logContents, log => log.Contains("(stderr): stderr-line", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task WithProcessCommand_GetCommandResult_CanCustomizeResultFromProcessOutput()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(
            exitCode: 42,
            outputEvents:
            [
                Output("""{"status":"custom"}"""),
                Error("diagnostic-line")
            ]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        ProcessCommandResultContext? capturedContext = null;
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "custom-result",
                "Custom result",
                _ => CreateProcessCommandSpec(arguments: ["custom-argument"]),
                new ProcessCommandOptions
                {
                    SuccessExitCodes = [0],
                    GetCommandResult = context =>
                    {
                        capturedContext = context;
 
                        return Task.FromResult(new ExecuteCommandResult
                        {
                            Success = true,
                            Data = new CommandResultData
                            {
                                Value = context.Output[0],
                                Format = CommandResultFormat.Json,
                                DisplayImmediately = false
                            }
                        });
                    }
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "custom-result").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Equal(CommandResultFormat.Json, result.Data?.Format);
        Assert.False(result.Data?.DisplayImmediately);
        Assert.Equal("""{"status":"custom"}""", result.Data?.Value);
 
        Assert.NotNull(capturedContext);
        Assert.Equal(42, capturedContext.ExitCode);
        Assert.Equal(resource.Resource.Name, capturedContext.ResourceName);
        Assert.NotNull(capturedContext.Services);
        Assert.NotNull(capturedContext.Logger);
        Assert.Equal(["custom-argument"], capturedContext.ProcessCommandSpec.Arguments);
        Assert.Equal(["{\"status\":\"custom\"}", "diagnostic-line"], capturedContext.Output);
        Assert.Equal(2, capturedContext.TotalOutputLineCount);
        var formattedOutput = capturedContext.GetFormattedOutput(maxLines: 1);
        Assert.Contains("Command output truncated: showing last 1 of 2 lines.", formattedOutput);
        Assert.Contains("diagnostic-line", formattedOutput);
    }
 
    [Fact]
    public async Task WithProcessCommand_ReturnsOutputInRunnerObservedOrder()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(
            outputEvents:
            [
                Output("stdout-1"),
                Error("stderr-1"),
                Output("stdout-2")
            ]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "ordered-output",
                "Ordered output",
                _ => CreateProcessCommandSpec());
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "ordered-output").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.NotNull(result.Data);
        Assert.Equal(
            ["stdout-1", "stderr-1", "stdout-2"],
            result.Data.Value.Split(Environment.NewLine, StringSplitOptions.None));
    }
 
    [Fact]
    public async Task WithProcessCommand_NonZeroExit_ReturnsFailureWithBoundedOutput()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(exitCode: 7, output: ["failed-line"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "fail",
                "Fail",
                _ => CreateProcessCommandSpec(),
                new ProcessCommandOptions
                {
                    MaxOutputLineCount = 5
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "fail").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.False(result.Canceled);
        Assert.NotEqual("Unhandled exception thrown.", result.Message);
        Assert.Contains("exited with code 7", result.Message);
        Assert.Contains("configured success exit codes [0]", result.Message);
        Assert.Equal(CommandResultFormat.Text, result.Data?.Format);
        Assert.True(result.Data?.DisplayImmediately);
        Assert.Contains("failed-line", result.Data?.Value);
    }
 
    [Fact]
    public async Task WithProcessCommand_PreservesArgumentValues()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
        string[] expectedArguments =
        [
            "value with spaces",
            "quote\"value",
            "",
            "$HOME && rm -rf /",
            "semi;colon"
        ];
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "argument-fidelity",
                "Argument fidelity",
                _ => CreateProcessCommandSpec(arguments: expectedArguments));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "argument-fidelity").DefaultTimeout();
 
        Assert.True(result.Success);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal(expectedArguments, processSpec.ArgumentList);
        Assert.Null(processSpec.Arguments);
    }
 
    [Fact]
    public async Task WithProcessCommand_StandardInputContent_FlowsToProcessSpec()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "stdin",
                "Standard input",
                _ => CreateProcessCommandSpec(standardInputContent: "from-standard-input"));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "stdin").DefaultTimeout();
 
        Assert.True(result.Success);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("from-standard-input", processSpec.StandardInputContent);
    }
 
    [Fact]
    public async Task WithProcessCommand_EnablesExecutablePathResolution()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "path-executable",
                "Path executable",
                _ => CreateProcessCommandSpec("test-command-name"));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "path-executable").DefaultTimeout();
 
        Assert.True(result.Success);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("test-command-name", processSpec.ExecutablePath);
        Assert.True(processSpec.ResolveExecutablePath);
    }
 
    [Fact]
    public async Task WithProcessCommand_WorkingDirectory_FlowsToProcessSpec()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "working-directory",
                "Working directory",
                _ => CreateProcessCommandSpec(workingDirectory: "/test/working-directory"));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "working-directory").DefaultTimeout();
 
        Assert.True(result.Success);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("/test/working-directory", processSpec.WorkingDirectory);
    }
 
    [Fact]
    public async Task WithProcessCommand_EnvironmentVariables_FlowToProcessSpec()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var environmentVariables = new Dictionary<string, string>
        {
            ["PROCESS_COMMAND_TEST_VALUE"] = "from-environment"
        };
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "environment",
                "Environment",
                _ => CreateProcessCommandSpec(environmentVariables: environmentVariables));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "environment").DefaultTimeout();
 
        Assert.True(result.Success);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("from-environment", processSpec.EnvironmentVariables["PROCESS_COMMAND_TEST_VALUE"]);
    }
 
    [Fact]
    public async Task WithProcessCommand_InvalidEnvironmentVariableName_ReturnsFailureWithoutCreatingProcess()
    {
        var processRunner = new TestProcessRunner();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "invalid-environment-name",
                "Invalid environment name",
                _ => CreateProcessCommandSpec(
                    environmentVariables: new Dictionary<string, string>
                    {
                        [""] = "value"
                    }));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "invalid-environment-name").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.Contains("environment variables require non-empty names", result.Message);
        Assert.Empty(processRunner.ProcessSpecs);
    }
 
    [Fact]
    public async Task WithProcessCommand_NullEnvironmentVariableValue_ReturnsFailureWithoutCreatingProcess()
    {
        var processRunner = new TestProcessRunner();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "null-environment-value",
                "Null environment value",
                _ => CreateProcessCommandSpec(
                    environmentVariables: new Dictionary<string, string>
                    {
                        ["PROCESS_COMMAND_TEST_VALUE"] = null!
                    }));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "null-environment-value").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.Contains("environment variable 'PROCESS_COMMAND_TEST_VALUE' requires a value", result.Message);
        Assert.Empty(processRunner.ProcessSpecs);
    }
 
    [Fact]
    public async Task WithProcessCommandExport_ProcessOptionsFlowToProcessSpec()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["export-options-output"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandExport(
                "export-options",
                "Export options",
                new ProcessCommandExportOptions
                {
                    ExecutablePath = "export-executable",
                    Arguments = ["--from-export"],
                    WorkingDirectory = "/test/export-working-directory",
                    EnvironmentVariables = new Dictionary<string, string>
                    {
                        ["PROCESS_COMMAND_EXPORT_VALUE"] = "from-export-environment"
                    },
                    InheritEnvironmentVariables = false,
                    StandardInputContent = "from-export-stdin",
                    KillEntireProcessTree = false,
                    MaxOutputLineCount = 10,
                    DisplayImmediately = false,
                    SuccessExitCodes = [0, 17]
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "export-options").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.NotNull(result.Data);
        Assert.False(result.Data.DisplayImmediately);
        Assert.Contains("export-options-output", result.Data.Value);
 
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("export-executable", processSpec.ExecutablePath);
        Assert.Equal(["--from-export"], processSpec.ArgumentList);
        Assert.Equal("/test/export-working-directory", processSpec.WorkingDirectory);
        Assert.Equal("from-export-environment", processSpec.EnvironmentVariables["PROCESS_COMMAND_EXPORT_VALUE"]);
        Assert.False(processSpec.InheritEnv);
        Assert.Equal("from-export-stdin", processSpec.StandardInputContent);
        Assert.False(processSpec.KillEntireProcessTree);
        Assert.Equal(10, processSpec.RetainedOutputLineCount);
    }
 
    [Fact]
    public async Task WithProcessCommandExport_SuccessExitCodes_TreatsConfiguredExitCodeAsSuccess()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(exitCode: 17, output: ["export-accepted-output"]);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandExport(
                "export-accepted-exit-code",
                "Export accepted exit code",
                new ProcessCommandExportOptions
                {
                    ExecutablePath = "export-executable",
                    SuccessExitCodes = [0, 17]
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "export-accepted-exit-code").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Contains("export-accepted-output", result.Data?.Value);
    }
 
    [Fact]
    public async Task WithProcessCommandExport_UnspecifiedBooleanOptionsUseProcessCommandSpecDefaults()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandExport(
                "export-default-options",
                "Export default options",
                new ProcessCommandExportOptions
                {
                    ExecutablePath = "export-executable"
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "export-default-options").DefaultTimeout();
 
        Assert.True(result.Success);
 
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.True(processSpec.InheritEnv);
        Assert.True(processSpec.KillEntireProcessTree);
    }
 
    [Fact]
    public async Task WithProcessCommandExport_InvalidProcessOptions_ReturnsFailureWithoutCreatingProcess()
    {
        var invalidOptions = new (ProcessCommandExportOptions Options, string ExpectedMessage)[]
        {
            (
                new ProcessCommandExportOptions
                {
                    ExecutablePath = ""
                },
                "requires a non-empty executable path"),
            (
                new ProcessCommandExportOptions
                {
                    ExecutablePath = "test-command",
                    Arguments = ["valid", null!]
                },
                "arguments cannot contain null values"),
            (
                new ProcessCommandExportOptions
                {
                    ExecutablePath = "test-command",
                    EnvironmentVariables = new Dictionary<string, string>
                    {
                        [" "] = "value"
                    }
                },
                "environment variables require non-empty names"),
            (
                new ProcessCommandExportOptions
                {
                    ExecutablePath = "test-command",
                    EnvironmentVariables = new Dictionary<string, string>
                    {
                        ["PROCESS_COMMAND_EXPORT_VALUE"] = null!
                    }
                },
                "environment variable 'PROCESS_COMMAND_EXPORT_VALUE' requires a value")
        };
 
        for (var i = 0; i < invalidOptions.Length; i++)
        {
            var (options, expectedMessage) = invalidOptions[i];
            var processRunner = new TestProcessRunner();
            using var builder = CreateTestDistributedApplicationBuilder(processRunner);
            var resource = builder.AddResource(new CustomResource($"resource-{i}"))
                .WithProcessCommandExport(
                    $"invalid-export-{i}",
                    "Invalid export",
                    options);
 
            using var app = builder.Build();
            await app.StartAsync().DefaultTimeout();
 
            var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, $"invalid-export-{i}").DefaultTimeout();
 
            Assert.False(result.Success);
            Assert.Contains(expectedMessage, result.Message);
            Assert.Empty(processRunner.ProcessSpecs);
        }
    }
 
    [Fact]
    public void WithProcessCommandExport_InvalidMaxOutputLineCount_ThrowsWhenAddingCommand()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var exception = Assert.Throws<DistributedApplicationException>(() =>
            builder.AddResource(new CustomResource("resource"))
                .WithProcessCommandExport(
                    "invalid-output-line-count",
                    "Invalid output line count",
                    new ProcessCommandExportOptions
                    {
                        ExecutablePath = "test-command",
                        MaxOutputLineCount = 0
                    }));
 
        Assert.Contains("output line count must be greater than zero", exception.Message);
    }
 
    [Fact]
    public async Task WithProcessCommandExport_EmptySuccessExitCodesUseDefault()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(exitCode: 1);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommandExport(
                "default-success-exit-codes",
                "Default success exit codes",
                new ProcessCommandExportOptions
                {
                    ExecutablePath = "test-command",
                    SuccessExitCodes = []
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "default-success-exit-codes").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.Contains("configured success exit codes [0]", result.Message);
    }
 
    [Fact]
    public async Task WithProcessCommand_CanDisableEnvironmentInheritance()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "environment-inheritance",
                "Environment inheritance",
                _ => CreateProcessCommandSpec(
                    arguments: ["inherited-variable"],
                    inheritEnvironmentVariables: false));
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "environment-inheritance").DefaultTimeout();
 
        Assert.True(result.Success);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.False(processSpec.InheritEnv);
    }
 
    [Fact]
    public async Task WithProcessCommand_ProcessStartFailure_ReturnsFailure()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueException(new InvalidOperationException("process start failed"));
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "missing",
                "Missing",
                "aspire-process-command-missing-executable");
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "missing").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.False(result.Canceled);
        Assert.Equal("process start failed", result.Message);
        Assert.NotEqual("Unhandled exception thrown.", result.Message);
    }
 
    [Fact]
    public async Task WithProcessCommand_ProcessTaskFailure_ReturnsFailureAndDisposesProcess()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueuePending(Task.FromException<ProcessResult>(new InvalidOperationException("process failed after start")));
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "process-task-failure",
                "Process task failure",
                "test-command");
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "process-task-failure").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.False(result.Canceled);
        Assert.Equal("process failed after start", result.Message);
        Assert.Contains(processRunner.Disposables, disposable => disposable.DisposeCallCount > 0);
    }
 
    [Fact]
    public async Task WithProcessCommand_NullProcessSpecFactoryResult_ReturnsFailure()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
        Func<ExecuteCommandContext, ValueTask<ProcessCommandSpec>> processSpecFactory = _ => new ValueTask<ProcessCommandSpec>((ProcessCommandSpec)null!);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "null-process-spec",
                "Null process spec",
                processSpecFactory);
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "null-process-spec").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.Contains("factory returned null", result.Message);
    }
 
    [Fact]
    public async Task WithProcessCommand_ProcessSpecFactoryException_ReturnsFailureWithoutCreatingProcess()
    {
        var processRunner = new TestProcessRunner();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        static ProcessCommandSpec CreateSpec(ExecuteCommandContext _)
        {
            throw new InvalidOperationException("factory failed");
        }
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "factory-exception",
                "Factory exception",
                CreateSpec);
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "factory-exception").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.Equal("factory failed", result.Message);
        Assert.Empty(processRunner.ProcessSpecs);
    }
 
    [Fact]
    public async Task WithProcessCommand_NullArgument_ReturnsFailure()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "null-argument",
                "Null argument",
                _ => new ProcessCommandSpec("test-command")
                {
                    Arguments = [null!]
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "null-argument").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.Contains("arguments cannot contain null values", result.Message);
    }
 
    [Fact]
    public async Task WithProcessCommand_ValidationFailure_DoesNotCreateProcess()
    {
        var processRunner = new TestProcessRunner();
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
        var processSpecFactoryCalled = false;
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "validation-failure",
                "Validation failure",
                _ =>
                {
                    processSpecFactoryCalled = true;
                    return CreateProcessCommandSpec();
                },
                new ProcessCommandOptions
                {
                    Arguments =
                    [
                        new InteractionInput
                        {
                            Name = "required",
                            InputType = InputType.Text,
                            Required = true
                        }
                    ]
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "validation-failure").DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.False(processSpecFactoryCalled);
        Assert.Empty(processRunner.ProcessSpecs);
        Assert.Equal("Command argument validation failed.", result.Message);
        Assert.NotNull(result.InvalidArguments);
        var invalidArgument = Assert.Single(result.InvalidArguments);
        Assert.Equal("required", invalidArgument.Name);
        Assert.Equal("Value is required.", Assert.Single(invalidArgument.ValidationErrors));
    }
 
    [Fact]
    public async Task WithProcessCommand_Cancellation_ReturnsCanceledResultAndDisposesProcess()
    {
        var processRunner = new TestProcessRunner();
        var pendingProcessResult = new TaskCompletionSource<ProcessResult>(TaskCreationOptions.RunContinuationsAsynchronously);
        processRunner.EnqueuePending(pendingProcessResult.Task);
        using var builder = CreateTestDistributedApplicationBuilder(processRunner);
 
        var resource = builder.AddResource(new CustomResource("resource"))
            .WithProcessCommand(
                "wait",
                "Wait",
                _ => CreateProcessCommandSpec());
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        using var cts = new CancellationTokenSource();
        var commandTask = app.ResourceCommands.ExecuteCommandAsync(resource.Resource, "wait", cts.Token);
 
        await processRunner.RunStarted.Task.DefaultTimeout();
        await cts.CancelAsync();
 
        var result = await commandTask.DefaultTimeout();
 
        Assert.False(result.Success);
        Assert.True(result.Canceled);
        var disposable = Assert.Single(processRunner.Disposables);
        Assert.Equal(1, disposable.DisposeCallCount);
    }
 
    private IDistributedApplicationTestingBuilder CreateTestDistributedApplicationBuilder(TestProcessRunner? processRunner = null)
    {
        var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        builder.Services.AddSingleton<IProcessRunner>(processRunner ?? new TestProcessRunner());
 
        return builder;
    }
 
    private static ProcessCommandSpec CreateProcessCommandSpec(
        string executablePath = "test-command",
        IReadOnlyList<string>? arguments = null,
        string? workingDirectory = null,
        IDictionary<string, string>? environmentVariables = null,
        bool inheritEnvironmentVariables = true,
        string? standardInputContent = null,
        bool killEntireProcessTree = true)
    {
        return new ProcessCommandSpec(executablePath)
        {
            Arguments = arguments ?? [],
            WorkingDirectory = workingDirectory,
            EnvironmentVariables = environmentVariables ?? new Dictionary<string, string>(),
            InheritEnvironmentVariables = inheritEnvironmentVariables,
            StandardInputContent = standardInputContent,
            KillEntireProcessTree = killEntireProcessTree
        };
    }
 
    private static TestProcessOutput Output(string value) => new(false, value);
 
    private static TestProcessOutput Error(string value) => new(true, value);
 
    private sealed class CustomResource(string name) : Resource(name)
    {
    }
}
 
#pragma warning restore ASPIREPROCESSCOMMAND001