File: Dashboard\DashboardServiceTests.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.Diagnostics;
using System.IO.Pipelines;
using System.Text;
using System.Threading.Channels;
using Aspire.DashboardService.Proto.V1;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Tests.Helpers;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Tests.Utils.Grpc;
using Aspire.Hosting.Utils;
using Aspire.Shared.ConsoleLogs;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Hex1b;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using DashboardServiceImpl = Aspire.Hosting.Dashboard.DashboardService;
using Resource = Aspire.Hosting.ApplicationModel.Resource;
using WriteContext = Microsoft.Extensions.Logging.Testing.WriteContext;
 
#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API.
 
namespace Aspire.Hosting.Tests.Dashboard;
 
[Trait("Partition", "3")]
public class DashboardServiceTests(ITestOutputHelper testOutputHelper)
{
 
    [Fact]
    public async Task WatchResourceConsoleLogs_NoFollow_ResultsEnd()
    {
        // Arrange
        const int LongLineCharacters = DashboardServiceImpl.LogMaxBatchCharacters / 3;
 
        var getConsoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<LogEntry>>();
        var consoleLogsService = new TestConsoleLogsService(name => getConsoleLogsChannel);
 
        var resourceLoggerService = new ResourceLoggerService();
        resourceLoggerService.SetConsoleLogsService(consoleLogsService);
 
        var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
        var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
        var dashboardService = CreateDashboardService(dashboardServiceData);
 
        var logger = resourceLoggerService.GetLogger("test-resource");
 
        // Three long lines
        logger.LogInformation(new string('1', LongLineCharacters));
        logger.LogInformation(new string('2', LongLineCharacters));
        logger.LogInformation(new string('3', LongLineCharacters));
        logger.LogInformation("Test1");
        logger.LogInformation("Test2");
 
        var context = TestServerCallContext.Create();
        var writer = new TestServerStreamWriter<WatchResourceConsoleLogsUpdate>(context);
 
        // Act
        var task = dashboardService.WatchResourceConsoleLogs(
            new WatchResourceConsoleLogsRequest { ResourceName = "test-resource", SuppressFollow = true },
            writer,
            context);
 
        // Assert
        var update1 = await writer.ReadNextAsync().DefaultTimeout();
        Assert.Collection(update1.LogLines,
            l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length),
            l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length));
 
        var update2 = await writer.ReadNextAsync().DefaultTimeout();
        Assert.Collection(update2.LogLines,
            l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length),
            l => Assert.Equal("Test1", l.Text.Split(' ')[1]),
            l => Assert.Equal("Test2", l.Text.Split(' ')[1]));
 
        await getConsoleLogsChannel.Writer.WriteAsync([LogEntry.Create(null, "Test3", isErrorMessage: false)]);
 
        var update3 = await writer.ReadNextAsync().DefaultTimeout();
        Assert.Collection(update3.LogLines,
            l => Assert.Equal("Test3", l.Text));
 
        Assert.False(task.IsCompleted, "Waiting for channel to complete.");
 
        getConsoleLogsChannel.Writer.TryComplete();
 
        await task.DefaultTimeout();
    }
 
    [Fact]
    public async Task WatchResourceConsoleLogs_LargePendingData_BatchResults()
    {
        // Arrange
        const int LongLineCharacters = DashboardServiceImpl.LogMaxBatchCharacters / 3;
        var resourceLoggerService = new ResourceLoggerService();
        var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
        var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
        var dashboardService = CreateDashboardService(dashboardServiceData);
 
        var logger = resourceLoggerService.GetLogger("test-resource");
 
        // Exceed limit line
        logger.LogInformation(new string('1', DashboardServiceImpl.LogMaxBatchCharacters));
        // Three long lines
        logger.LogInformation(new string('2', LongLineCharacters));
        logger.LogInformation(new string('3', LongLineCharacters));
        logger.LogInformation(new string('4', LongLineCharacters));
 
        var context = TestServerCallContext.Create();
        var writer = new TestServerStreamWriter<WatchResourceConsoleLogsUpdate>(context);
 
        // Act
        var task = dashboardService.WatchResourceConsoleLogs(
            new WatchResourceConsoleLogsRequest { ResourceName = "test-resource" },
            writer,
            context);
 
        // Assert
        var exceedLimitUpdate = await writer.ReadNextAsync().DefaultTimeout();
        Assert.Collection(exceedLimitUpdate.LogLines,
            l => Assert.Equal(DashboardServiceImpl.LogMaxBatchCharacters, l.Text.Length));
 
        var longLinesUpdate1 = await writer.ReadNextAsync().DefaultTimeout();
        Assert.Collection(longLinesUpdate1.LogLines,
            l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length),
            l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length));
 
        var longLinesUpdate2 = await writer.ReadNextAsync().DefaultTimeout();
        Assert.Collection(longLinesUpdate2.LogLines,
            l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length));
 
        resourceLoggerService.Complete("test-resource");
        await task.DefaultTimeout();
    }
 
    [Fact]
    public async Task WatchResources_ResourceHasCommands_CommandsSentWithResponse()
    {
        // Arrange
        var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Trace);
            builder.AddXunit(testOutputHelper);
        });
 
        var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
        var resourceLoggerService = new ResourceLoggerService();
        var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
        using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
        var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
 
        var testResource = new TestResource("test-resource");
        using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
        var builder = applicationBuilder.AddResource(testResource);
#pragma warning disable CS0618 // Parameter is obsolete but this verifies dashboard wire compatibility.
        builder.WithCommand(
            name: "TestName",
            displayName: "Display name!",
            executeCommand: c => Task.FromResult(CommandResults.Success()),
            commandOptions: new()
            {
                UpdateState = c => Hosting.ApplicationModel.ResourceCommandState.Enabled,
                Description = "Display description!",
                Parameter = new[] { "One", "Two" },
                Arguments =
                [
                    new InteractionInput
                    {
                        Name = "selector",
                        Label = "Selector",
                        Description = "CSS selector to click.",
                        InputType = InputType.Text,
                        Required = true,
                        Placeholder = "#submit"
                    }
                ],
                ConfirmationMessage = "Confirmation message!",
                IconName = "Icon name!",
                IconVariant = Hosting.ApplicationModel.IconVariant.Filled,
                IsHighlighted = true
            });
#pragma warning restore CS0618
        builder.WithCommand(
            name: "HeadlessName",
            displayName: "Headless display name",
            executeCommand: c => Task.FromResult(CommandResults.Success()),
            commandOptions: new()
            {
                UpdateState = c => Hosting.ApplicationModel.ResourceCommandState.Enabled,
                Visibility = ResourceCommandVisibility.Api
            });
        builder.WithCommand(
            name: "UnknownStateName",
            displayName: "Unknown state display name",
            executeCommand: c => Task.FromResult(CommandResults.Success()),
            commandOptions: new()
            {
                UpdateState = c => (Hosting.ApplicationModel.ResourceCommandState)999
            });
 
        logger.LogInformation("Publishing resource.");
        await resourceNotificationService.PublishUpdateAsync(testResource, s =>
        {
            return s with { State = new ResourceStateSnapshot("Starting", null) };
        }).DefaultTimeout();
 
        logger.LogInformation("Waiting for the resource with a command. Required so added resource is always in the service's initial data collection");
        await dashboardServiceData.WaitForResourceAsync(testResource.Name, r =>
        {
            return r.Commands.Length == 3;
        }).DefaultTimeout();
 
        var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writer = new TestServerStreamWriter<WatchResourcesUpdate>(context);
 
        // Act
        logger.LogInformation("Calling WatchResources.");
        var task = dashboardService.WatchResources(
            new WatchResourcesRequest(),
            writer,
            context);
 
        logger.LogInformation("Reading result from writer.");
        var readUpdateTask = writer.ReadNextAsync().DefaultTimeout();
        var completedTask = await Task.WhenAny(task, readUpdateTask).DefaultTimeout();
        await completedTask.DefaultTimeout();
        Assert.Same(readUpdateTask, completedTask);
 
        var update = await readUpdateTask;
        Assert.False(task.IsCompleted, "WatchResources should remain active until cancellation.");
 
        logger.LogInformation($"Initial data count: {update.InitialData.Resources.Count}");
        var resourceData = Assert.Single(update.InitialData.Resources);
 
        logger.LogInformation($"Commands count: {resourceData.Commands.Count}");
        Assert.Collection(resourceData.Commands,
            commandData =>
            {
                Assert.Equal("TestName", commandData.Name);
                Assert.Equal("Display name!", commandData.DisplayName);
                Assert.Equal("Display description!", commandData.DisplayDescription);
#pragma warning disable CS0612 // Parameter is obsolete but still verified for compatibility.
                Assert.Equal(Value.ForList(Value.ForString("One"), Value.ForString("Two")), commandData.Parameter);
#pragma warning restore CS0612
                var argumentInput = Assert.Single(commandData.ArgumentInputs);
                Assert.Equal("selector", argumentInput.Name);
                Assert.Equal("Selector", argumentInput.Label);
                Assert.Equal("CSS selector to click.", argumentInput.Description);
                Assert.Equal(DashboardService.Proto.V1.InputType.Text, argumentInput.InputType);
                Assert.True(argumentInput.Required);
                Assert.Equal("#submit", argumentInput.Placeholder);
                Assert.Equal("Confirmation message!", commandData.ConfirmationMessage);
                Assert.Equal("Icon name!", commandData.IconName);
                Assert.Equal(DashboardService.Proto.V1.IconVariant.Filled, commandData.IconVariant);
                Assert.True(commandData.IsHighlighted);
                Assert.Equal(DashboardService.Proto.V1.ResourceCommandState.Enabled, commandData.State);
            },
            commandData =>
            {
                Assert.Equal("UnknownStateName", commandData.Name);
                Assert.Equal("Unknown state display name", commandData.DisplayName);
                Assert.Equal(DashboardService.Proto.V1.ResourceCommandState.Hidden, commandData.State);
            });
 
        await CancelTokenAndAwaitTask(cts, task).DefaultTimeout();
    }
 
    [Fact]
    public async Task ExecuteResourceCommand_WithArguments_PassesArgumentsToCommand()
    {
        var resourceLoggerService = new ResourceLoggerService();
        var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
        using var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
        var dashboardService = CreateDashboardService(dashboardServiceData);
 
        InteractionInputCollection? capturedArguments = null;
        var testResource = new TestResource("test-resource");
        using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
        var builder = applicationBuilder.AddResource(testResource);
        builder.WithCommand(
            name: "click",
            displayName: "Click",
            executeCommand: c =>
            {
                capturedArguments = c.Arguments;
                return Task.FromResult(CommandResults.Success());
            },
            commandOptions: new()
            {
                Visibility = ResourceCommandVisibility.Api,
                Arguments =
                [
                    new InteractionInput
                    {
                        Name = "selector",
                        InputType = InputType.Text
                    },
                    new InteractionInput
                    {
                        Name = "clickCount",
                        InputType = InputType.Number
                    }
                ]
            });
 
        await resourceNotificationService.PublishUpdateAsync(testResource, s =>
        {
            return s with { State = new ResourceStateSnapshot("Running", null) };
        }).DefaultTimeout();
 
        var context = TestServerCallContext.Create();
        var response = await dashboardService.ExecuteResourceCommand(
            new ResourceCommandRequest
            {
                ResourceName = testResource.Name,
                CommandName = "click",
                Arguments =
                {
                    ["selector"] = Value.ForString("#submit"),
                    ["clickCount"] = Value.ForNumber(2)
                }
            },
            context);
 
        Assert.Equal(ResourceCommandResponseKind.Succeeded, response.Kind);
        Assert.NotNull(capturedArguments);
        Assert.Equal("#submit", capturedArguments.GetString("selector"));
        Assert.Equal(2, capturedArguments.GetInt32("clickCount"));
    }
 
    [Fact]
    public async Task ExecuteResourceCommand_WithUnknownArgument_ReturnsFailure()
    {
        var resourceLoggerService = new ResourceLoggerService();
        var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
        using var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
        var dashboardService = CreateDashboardService(dashboardServiceData);
 
        var executed = false;
        var testResource = new TestResource("test-resource");
        using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
        var builder = applicationBuilder.AddResource(testResource);
        builder.WithCommand(
            name: "click",
            displayName: "Click",
            executeCommand: c =>
            {
                executed = true;
                return Task.FromResult(CommandResults.Success());
            },
            commandOptions: new()
            {
                Visibility = ResourceCommandVisibility.Api,
                Arguments =
                [
                    new InteractionInput
                    {
                        Name = "selector",
                        InputType = InputType.Text,
                        Required = true
                    }
                ]
            });
 
        await resourceNotificationService.PublishUpdateAsync(testResource, s =>
        {
            return s with { State = new ResourceStateSnapshot("Running", null) };
        }).DefaultTimeout();
 
        var context = TestServerCallContext.Create();
        var response = await dashboardService.ExecuteResourceCommand(
            new ResourceCommandRequest
            {
                ResourceName = testResource.Name,
                CommandName = "click",
                Arguments =
                {
                    ["selecter"] = Value.ForString("#submit")
                }
            },
            context);
 
        Assert.Equal(ResourceCommandResponseKind.Failed, response.Kind);
        Assert.False(executed);
        Assert.Equal("Unknown argument 'selecter' for command 'click'.", response.Message);
    }
 
    [Fact]
    public async Task ExecuteResourceCommand_WithInvalidArguments_ReturnsValidationErrors()
    {
        var resourceLoggerService = new ResourceLoggerService();
        var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
        using var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
        var dashboardService = CreateDashboardService(dashboardServiceData);
 
        var executed = false;
        var testResource = new TestResource("test-resource");
        using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
        var builder = applicationBuilder.AddResource(testResource);
        builder.WithCommand(
            name: "validate",
            displayName: "Validate",
            executeCommand: c =>
            {
                executed = true;
                return Task.FromResult(CommandResults.Success());
            },
            commandOptions: new()
            {
                Visibility = ResourceCommandVisibility.Api,
                Arguments =
                [
                    new InteractionInput
                    {
                        Name = "target",
                        InputType = InputType.Text
                    }
                ],
                ValidateArguments = context =>
                {
                    var target = context.Inputs.Single(argument => argument.Name == "target");
                    context.AddValidationError(target, "Target must not be prod.");
 
                    return Task.CompletedTask;
                }
            });
 
        await resourceNotificationService.PublishUpdateAsync(testResource, s =>
        {
            return s with { State = new ResourceStateSnapshot("Running", null) };
        }).DefaultTimeout();
 
        var context = TestServerCallContext.Create();
        var response = await dashboardService.ExecuteResourceCommand(
            new ResourceCommandRequest
            {
                ResourceName = testResource.Name,
                CommandName = "validate",
                Arguments =
                {
                    ["target"] = Value.ForString("prod")
                }
            },
            context);
 
        Assert.Equal(ResourceCommandResponseKind.InvalidArguments, response.Kind);
        Assert.Equal("Command argument validation failed.", response.Message);
        Assert.False(executed);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    [InlineData(null)]
    public async Task WatchInteractions_PromptMessageBoxAsync_CompleteOnResponse(bool? result)
    {
        // Arrange
        var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Trace);
            builder.AddXunit(testOutputHelper);
        });
 
        var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
        var interactionService = new InteractionService(
            loggerFactory.CreateLogger<InteractionService>(),
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            new TestInteractionFileUploadStore());
        using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
        var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
 
        var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
        var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
 
        // Act
        logger.LogInformation("Calling WatchInteractions.");
        var task = dashboardService.WatchInteractions(
            reader,
            writer,
            context);
 
        var resultTask = interactionService.PromptMessageBoxAsync(
            title: "Title!",
            message: "Message!");
 
        // Assert
        logger.LogInformation("Reading result from writer.");
        var update = await writer.ReadNextAsync().DefaultTimeout();
 
        Assert.NotEqual(0, update.InteractionId);
        Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.MessageBox, update.KindCase);
 
        Assert.False(resultTask.IsCompleted);
 
        logger.LogInformation("Send result to reader.");
        if (result != null)
        {
            update.MessageBox.Result = result.Value;
            reader.AddMessage(new WatchInteractionsRequestUpdate
            {
                InteractionId = update.InteractionId,
                MessageBox = update.MessageBox
            });
 
            Assert.Equal(result, (await resultTask.DefaultTimeout()).Data);
        }
        else
        {
            reader.AddMessage(new WatchInteractionsRequestUpdate
            {
                InteractionId = update.InteractionId,
                Complete = new InteractionComplete()
            });
 
            Assert.True((await resultTask.DefaultTimeout()).Canceled);
        }
 
        await CancelTokenAndAwaitTask(cts, task).DefaultTimeout();
    }
 
    [Fact]
    public async Task WatchInteractions_NoExplicitLabel_LabelIsName()
    {
        // Arrange
        var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Trace);
            builder.AddXunit(testOutputHelper);
        });
 
        var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
        var interactionService = new InteractionService(
            loggerFactory.CreateLogger<InteractionService>(),
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            new TestInteractionFileUploadStore());
        using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
        var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
 
        var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
        var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
 
        // Act
        logger.LogInformation("Calling WatchInteractions.");
        var task = dashboardService.WatchInteractions(
            reader,
            writer,
            context);
 
        var resultTask = interactionService.PromptInputAsync(
            title: "Title!",
            message: "Message!",
            new InteractionInput { Name = "Input", InputType = InputType.Text });
 
        // Assert
        logger.LogInformation("Reading result from writer.");
        var update = await writer.ReadNextAsync().DefaultTimeout();
 
        Assert.NotEqual(0, update.InteractionId);
        Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog, update.KindCase);
        Assert.Equal("Input", Assert.Single(update.InputsDialog.InputItems).Label);
 
        await CancelTokenAndAwaitTask(cts, task).DefaultTimeout();
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    [InlineData(null)]
    public async Task WatchInteractions_PromptTerminalAsync_DedicatedPayloadAndCompletion(bool? result)
    {
        await using var terminals = TestTerminalService.Create();
        using var services = new ServiceCollection().AddSingleton(terminals).BuildServiceProvider();
        var interactionService = new InteractionService(
            NullLogger<InteractionService>.Instance, new DistributedApplicationOptions(), services,
            new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore());
        using var data = CreateDashboardServiceData(interactionService: interactionService);
        var dashboard = CreateDashboardService(data, terminalService: terminals);
        await using var terminal = terminals.CreateTerminal(new TerminalLaunchOptions
        {
            Title = "Shell", Executable = "must-not-be-started", Placement = TerminalPlacement.Dialog
        });
        using var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
        var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
        var watch = dashboard.WatchInteractions(reader, writer, context);
        var prompt = interactionService.PromptTerminalAsync("Message", terminal,
            new TerminalInteractionOptions { Title = "Dialog", PrimaryButtonText = "Cancel" }, cts.Token);
 
        var update = await writer.ReadNextAsync().DefaultTimeout();
        Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.PromptTerminal, update.KindCase);
        Assert.Equal("Dialog", update.Title);
        Assert.Equal("Message", update.Message);
        Assert.Equal("Cancel", update.PrimaryButtonText);
        Assert.Equal(terminal.Id, update.PromptTerminal.TerminalId);
        Assert.False(update.PromptTerminal.HasResult);
        Assert.False(prompt.IsCompleted);
 
        var response = new WatchInteractionsRequestUpdate { InteractionId = update.InteractionId };
        if (result is { } value)
        {
            response.PromptTerminal = new InteractionPromptTerminal { TerminalId = terminal.Id, Result = value };
        }
        else
        {
            response.Complete = new InteractionComplete();
        }
        reader.AddMessage(response);
        var promptResult = await prompt.DefaultTimeout();
        Assert.Equal(result != true, promptResult.Canceled);
        Assert.Equal(result == true, promptResult.Data);
        var complete = await writer.ReadNextAsync().DefaultTimeout();
        Assert.Equal(update.InteractionId, complete.InteractionId);
        Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.Complete, complete.KindCase);
        Assert.Empty(interactionService.GetCurrentInteractions());
        Assert.True(terminals.TryGetTerminal(terminal.Id, out var registered));
        Assert.Same(terminal, registered);
        await CancelTokenAndAwaitTask(cts, watch).DefaultTimeout();
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task SendInteractionRequestAsync_TerminalResponseMustMatchInteraction(bool wrongKind)
    {
        await using var terminals = TestTerminalService.Create();
        using var services = new ServiceCollection().AddSingleton(terminals).BuildServiceProvider();
        var interactions = new InteractionService(
            NullLogger<InteractionService>.Instance, new DistributedApplicationOptions(), services,
            new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore());
        using var data = CreateDashboardServiceData(interactionService: interactions);
        await using var terminal = terminals.CreateTerminal(new TerminalLaunchOptions
        {
            Title = "Shell", Executable = "must-not-be-started", Placement = TerminalPlacement.Dialog
        });
        using var cts = new CancellationTokenSource();
        var prompt = wrongKind
            ? interactions.PromptMessageBoxAsync("Title", "Message", cancellationToken: cts.Token)
            : interactions.PromptTerminalAsync("Message", terminal, cancellationToken: cts.Token);
        var interaction = Assert.Single(interactions.GetCurrentInteractions());
        var response = new WatchInteractionsRequestUpdate
        {
            InteractionId = interaction.InteractionId,
            PromptTerminal = new InteractionPromptTerminal
            {
                TerminalId = wrongKind ? terminal.Id : "different-terminal",
                Result = false
            }
        };
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => data.SendInteractionRequestAsync(response, CancellationToken.None));
        Assert.Equal("The terminal response must match the interaction's terminal.", ex.Message);
        Assert.Same(interaction, Assert.Single(interactions.GetCurrentInteractions()));
        Assert.False(prompt.IsCompleted);
        cts.Cancel();
        Assert.True((await prompt.DefaultTimeout()).Canceled);
    }
 
    [Fact]
    public void TerminalInteractionProtocol_UsesDedicatedMessages()
    {
        Assert.Equal(8, WatchInteractionsRequestUpdate.Descriptor.FindFieldByName("prompt_terminal").FieldNumber);
        Assert.Equal(21, WatchInteractionsResponseUpdate.Descriptor.FindFieldByName("prompt_terminal").FieldNumber);
        var input = Aspire.DashboardService.Proto.V1.InteractionInput.Descriptor.ToProto();
        Assert.Empty(input.ReservedName);
        Assert.Empty(input.ReservedRange);
        Assert.Null(Aspire.DashboardService.Proto.V1.InteractionInput.Descriptor.FindFieldByName("terminal_id"));
        var inputType = DashboardServiceReflection.Descriptor.EnumTypes.Single(type => type.Name == "InputType");
        Assert.Empty(inputType.ToProto().ReservedName);
        Assert.Empty(inputType.ToProto().ReservedRange);
        Assert.Null(inputType.FindValueByName("INPUT_TYPE_TERMINAL"));
        var clientFrame = TerminalClientFrame.Descriptor.ToProto();
        Assert.Empty(clientFrame.ReservedName);
        Assert.Empty(clientFrame.ReservedRange);
        Assert.Equal(1, TerminalClientFrame.Descriptor.FindFieldByName("data").FieldNumber);
        Assert.Equal(2, TerminalClientFrame.Descriptor.FindFieldByName("terminal_id").FieldNumber);
    }
 
    [Fact]
    public async Task WatchInteractions_PromptInputAsync_CompleteOnCancelResponse()
    {
        // Arrange
        var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Trace);
            builder.AddXunit(testOutputHelper);
        });
 
        var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
        var interactionService = new InteractionService(
            loggerFactory.CreateLogger<InteractionService>(),
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            new TestInteractionFileUploadStore());
        using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
        var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
 
        var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
        var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
 
        // Act
        logger.LogInformation("Calling WatchInteractions.");
        var task = dashboardService.WatchInteractions(
            reader,
            writer,
            context);
 
        var resultTask = interactionService.PromptInputAsync(
            title: "Title!",
            message: "Message!",
            new InteractionInput { Name = "Input", InputType = InputType.Text, Label = "Input" });
 
        // Assert
        logger.LogInformation("Reading result from writer.");
        var update = await writer.ReadNextAsync().DefaultTimeout();
 
        Assert.NotEqual(0, update.InteractionId);
        Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog, update.KindCase);
 
        Assert.False(resultTask.IsCompleted);
 
        logger.LogInformation("Send result to reader.");
        reader.AddMessage(new WatchInteractionsRequestUpdate
        {
            InteractionId = update.InteractionId,
            Complete = new InteractionComplete()
        });
 
        var result = await resultTask.DefaultTimeout();
        Assert.True(result.Canceled);
        Assert.Null(result.Data);
 
        await CancelTokenAndAwaitTask(cts, task).DefaultTimeout();
    }
 
    [Fact]
    public async Task WatchInteractions_ReaderError_CompleteWithError()
    {
        // Arrange
        var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Trace);
            builder.AddXunit(testOutputHelper);
        });
 
        var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
        var interactionService = new InteractionService(
            loggerFactory.CreateLogger<InteractionService>(),
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            new TestInteractionFileUploadStore());
        using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
        var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
 
        var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
        var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
 
        // Act
        logger.LogInformation("Calling WatchInteractions.");
        var task = dashboardService.WatchInteractions(
            reader,
            writer,
            context);
 
        reader.Complete(new InvalidOperationException("Error!"));
 
        // Assert
        await Assert.ThrowsAnyAsync<Exception>(() => task).DefaultTimeout();
    }
 
    [Fact]
    public async Task WatchInteractions_WriterError_CompleteWithError()
    {
        // Arrange
        var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Trace);
            builder.AddXunit(testOutputHelper);
        });
 
        var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
        var interactionService = new InteractionService(
            loggerFactory.CreateLogger<InteractionService>(),
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            new TestInteractionFileUploadStore());
        using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
        var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
 
        var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
        var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
 
        // Act
        logger.LogInformation("Calling WatchInteractions.");
        var task = dashboardService.WatchInteractions(
            reader,
            writer,
            context);
 
        writer.Complete(new InvalidOperationException("Error!"));
 
        _ = interactionService.PromptMessageBoxAsync(
            title: "Title!",
            message: "Message!");
 
        // Assert
        await Assert.ThrowsAnyAsync<Exception>(() => task).DefaultTimeout();
    }
 
    [Fact]
    public void WithCommandOverloadNotAmbiguous()
    {
        var testResource = new TestResource("test-resource");
        using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
        var builder = applicationBuilder.AddResource(testResource);
        builder.WithCommand(
            name: "TestName",
            displayName: "Display name!",
            executeCommand: c => Task.FromResult(CommandResults.Success()));
 
        // This test simply needs to compile.
        Assert.True(true);
    }
 
    [Fact]
    public async Task GetApplicationInformation_ReadsFromConfiguration()
    {
        // Arrange
        var configBuilder = new ConfigurationBuilder();
        configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["AppHost:DashboardApplicationName"] = "MyCustomAppName"
        });
        var configuration = configBuilder.Build();
 
        var dashboardServiceData = CreateDashboardServiceData();
        var hostEnvironment = new TestHostEnvironment
        {
            ApplicationName = "DefaultAppName"
        };
        var dashboardService = CreateDashboardService(dashboardServiceData, hostEnvironment: hostEnvironment, configuration: configuration);
 
        var context = TestServerCallContext.Create();
 
        // Act
        var response = await dashboardService.GetApplicationInformation(
            new ApplicationInformationRequest(),
            context);
 
        // Assert
        Assert.Equal("MyCustomAppName", response.ApplicationName);
    }
 
    [Fact]
    public async Task GetApplicationInformation_FallsBackToEnvironmentApplicationName()
    {
        // Arrange
        var configuration = new ConfigurationBuilder().Build(); // Empty configuration
 
        var dashboardServiceData = CreateDashboardServiceData();
        var hostEnvironment = new TestHostEnvironment
        {
            ApplicationName = "FallbackAppName"
        };
        var dashboardService = CreateDashboardService(dashboardServiceData, hostEnvironment: hostEnvironment, configuration: configuration);
 
        var context = TestServerCallContext.Create();
 
        // Act
        var response = await dashboardService.GetApplicationInformation(
            new ApplicationInformationRequest(),
            context);
 
        // Assert
        Assert.Equal("FallbackAppName", response.ApplicationName);
    }
 
    [Fact]
    public async Task GetApplicationInformation_StripsAppHostSuffix()
    {
        // Arrange
        var configBuilder = new ConfigurationBuilder();
        configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["AppHost:DashboardApplicationName"] = "MyApp.AppHost"
        });
        var configuration = configBuilder.Build();
 
        var dashboardServiceData = CreateDashboardServiceData();
        var dashboardService = CreateDashboardService(dashboardServiceData, configuration: configuration);
 
        var context = TestServerCallContext.Create();
 
        // Act
        var response = await dashboardService.GetApplicationInformation(
            new ApplicationInformationRequest(),
            context);
 
        // Assert
        // The ComputeApplicationName method should strip the .AppHost suffix
        Assert.Equal("MyApp", response.ApplicationName);
    }
 
    [Theory]
    [InlineData(InputType.File, 0, -1, 0)]       // File, no input max, no server limit → 0
    [InlineData(InputType.File, 0, 50, 50)]      // File, no input max, 50 MB server limit → 50 MB
    [InlineData(InputType.File, 5, 100, 5)]      // File, 5 MB input max below 100 MB server → uses input
    [InlineData(InputType.File, 200, 100, 100)]  // File, 200 MB input max exceeds 100 MB server → capped
    [InlineData(InputType.File, 50, -1, 50)]     // File, 50 MB input max, no server limit → uses input
    [InlineData(InputType.Text, 0, 100, 0)]      // Non-file, server limit not applied → 0
    public void CreateInteractionInputDto_MaxFileSize_RespectsLimits(
        InputType inputType, int inputMaxFileSizeMB, int serverLimitMB, int expectedMB)
    {
        var input = inputMaxFileSizeMB > 0
            ? new InteractionInput { Name = "TestInput", Label = "Test Input", InputType = inputType, MaxFileSize = inputMaxFileSizeMB * 1024 * 1024 }
            : new InteractionInput { Name = "TestInput", Label = "Test Input", InputType = inputType };
 
        long? serverLimit = serverLimitMB >= 0 ? serverLimitMB * 1024L * 1024 : null;
        var dto = DashboardServiceImpl.CreateInteractionInputDto(input, maxFileUploadSize: serverLimit);
 
        Assert.Equal(expectedMB * 1024L * 1024, dto.MaxFileSize);
    }
 
    [Fact]
    public async Task UploadFile_WithinSizeLimit_Succeeds()
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
        var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
 
        var data = new byte[1024]; // 1 KB
        Array.Fill(data, (byte)'A');
 
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.AddMessage(new UploadFileChunk { FileName = "test.txt", Data = ByteString.CopyFrom(data), InteractionId = 1, InputName = "File" });
        requestStream.Complete();
 
        var response = await dashboardService.UploadFile(requestStream, context);
 
        Assert.NotNull(response.FileId);
        Assert.NotEmpty(response.FileId);
        var filePath = Assert.IsType<string>(fileUploadStore.GetFilePath(response.FileId, 1, "File"));
 
        fileUploadStore.CancelInteraction(1);
 
        Assert.Null(fileUploadStore.GetFilePath(response.FileId, 1, "File"));
        Assert.False(File.Exists(filePath));
    }
 
    [Theory]
    [InlineData("../../../etc/passwd", "passwd")]
    [InlineData("..\\..\\windows\\system32\\evil.exe", "evil.exe")]
    [InlineData("bad*.txt", "bad*.txt")]
    [InlineData("bad:name.txt", "bad:name.txt")]
    [InlineData("CON.txt", "CON.txt")]
    [InlineData("bad\0name.txt", "bad\0name.txt")]
    public async Task UploadFile_MaliciousFileName_UsesRandomDiskName(string fileName, string expectedFileName)
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("File", 1)]);
        var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
 
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.AddMessage(new UploadFileChunk { FileName = fileName, InteractionId = 1, InputName = "File" });
        requestStream.Complete();
 
        var response = await dashboardService.UploadFile(requestStream, context);
 
        var filePath = Assert.IsType<string>(fileUploadStore.GetFilePath(response.FileId, 1, "File"));
        Assert.True(File.Exists(filePath));
        Assert.NotEqual(expectedFileName, Path.GetFileName(filePath));
        Assert.Equal(expectedFileName, Assert.Single(fileUploadStore.GetCompletedFiles(1, "File")).Name);
    }
 
    [Fact]
    public async Task UploadFile_ExceedsInputFileCountLimit_ThrowsFailedPrecondition()
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("File", 1)]);
        var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
 
        var firstContext = TestServerCallContext.Create();
        var firstRequestStream = new TestAsyncStreamReader<UploadFileChunk>(firstContext);
        firstRequestStream.AddMessage(new UploadFileChunk { FileName = "first.txt", InteractionId = 1, InputName = "File" });
        firstRequestStream.Complete();
        await dashboardService.UploadFile(firstRequestStream, firstContext);
 
        var secondContext = TestServerCallContext.Create();
        var secondRequestStream = new TestAsyncStreamReader<UploadFileChunk>(secondContext);
        secondRequestStream.AddMessage(new UploadFileChunk { FileName = "second.txt", InteractionId = 1, InputName = "File" });
        secondRequestStream.Complete();
 
        var exception = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(secondRequestStream, secondContext));
        Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
        Assert.Equal("File input 'File' accepts at most 1 file.", exception.Status.Detail);
    }
 
    [Fact]
    public async Task UploadFile_ExceedsConfiguredSizeLimit_ThrowsResourceExhausted()
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [KnownConfigNames.MaxFileUploadSize] = "1024" // 1 KB limit
            })
            .Build();
        var dashboardService = CreateDashboardService(dashboardServiceData, configuration: configuration, fileUploadStore: fileUploadStore);
 
        var data = new byte[2048]; // 2 KB - exceeds the 1 KB limit
        Array.Fill(data, (byte)'A');
 
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.AddMessage(new UploadFileChunk { FileName = "large.txt", Data = ByteString.CopyFrom(data), InteractionId = 1, InputName = "File" });
        requestStream.Complete();
 
        var ex = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
        Assert.Equal(StatusCode.ResourceExhausted, ex.StatusCode);
 
        fileUploadStore.CancelInteraction(1);
        fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
        var (_, replacementPath) = fileUploadStore.CreateEntry("replacement.txt", 1, "File");
        Assert.True(File.Exists(replacementPath));
    }
 
    [Fact]
    public async Task UploadFile_ExceedsLimitAcrossMultipleChunks_ThrowsResourceExhausted()
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [KnownConfigNames.MaxFileUploadSize] = "1500" // 1500 bytes limit
            })
            .Build();
        var dashboardService = CreateDashboardService(dashboardServiceData, configuration: configuration, fileUploadStore: fileUploadStore);
 
        var chunk1 = new byte[1024]; // 1 KB
        var chunk2 = new byte[1024]; // 1 KB - total 2 KB exceeds 1500
        Array.Fill(chunk1, (byte)'A');
        Array.Fill(chunk2, (byte)'B');
 
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.AddMessage(new UploadFileChunk { FileName = "large.txt", Data = ByteString.CopyFrom(chunk1), InteractionId = 1, InputName = "File" });
        requestStream.AddMessage(new UploadFileChunk { Data = ByteString.CopyFrom(chunk2) });
        requestStream.Complete();
 
        var ex = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
        Assert.Equal(StatusCode.ResourceExhausted, ex.StatusCode);
    }
 
    [Fact]
    public async Task UploadFile_ConfiguredSizeLimit_AllowsWithinLimitUploads()
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [KnownConfigNames.MaxFileUploadSize] = "10485760" // 10 MB
            })
            .Build();
        var dashboardService = CreateDashboardService(dashboardServiceData, configuration: configuration, fileUploadStore: fileUploadStore);
 
        var data = new byte[1024 * 1024]; // 1 MB - within 10 MB limit
        Array.Fill(data, (byte)'A');
 
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.AddMessage(new UploadFileChunk { FileName = "medium.bin", Data = ByteString.CopyFrom(data), InteractionId = 1, InputName = "File" });
        requestStream.Complete();
 
        var response = await dashboardService.UploadFile(requestStream, context);
 
        Assert.NotNull(response.FileId);
        Assert.NotEmpty(response.FileId);
    }
 
    [Fact]
    public async Task UploadFile_EmptyStream_ThrowsInvalidArgument()
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
 
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.Complete(); // empty stream
 
        var ex = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
 
        Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
    }
 
    [Theory]
    [InlineData("", 1, "File", "First chunk must include a file name.")]
    [InlineData("file.txt", 0, "File", "First chunk must include an interaction ID.")]
    [InlineData("file.txt", 1, "", "First chunk must include an input name.")]
    public async Task UploadFile_FirstChunkMissingRequiredMetadata_ThrowsInvalidArgument(
        string fileName,
        int interactionId,
        string inputName,
        string expectedMessage)
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
        var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
 
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.AddMessage(new UploadFileChunk { FileName = fileName, InteractionId = interactionId, InputName = inputName });
        requestStream.Complete();
 
        var exception = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
 
        Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
        Assert.Equal(expectedMessage, exception.Status.Detail);
    }
 
    [Fact]
    public async Task UploadFile_UnknownInteraction_RejectsUpload()
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
 
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.AddMessage(new UploadFileChunk { FileName = "file.txt", InteractionId = 1, InputName = "File" });
        requestStream.Complete();
 
        var exception = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
 
        Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
        Assert.Equal("Interaction '1' is not accepting file uploads.", exception.Status.Detail);
    }
 
    [Fact]
    public async Task SendInteractionRequestAsync_ClientFileTypeForTextInput_DoesNotAttachFile()
    {
        var fileUploadStore = new TestInteractionFileUploadStore();
        var interactionService = new InteractionService(
            NullLogger<InteractionService>.Instance,
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            fileUploadStore);
        using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore);
        var fileInput = new InteractionInput { Name = "File", InputType = InputType.File };
        var textInput = new InteractionInput { Name = "Text", InputType = InputType.Text };
        var resultTask = interactionService.PromptInputsAsync("Inputs", "Enter values", [fileInput, textInput]);
        var interaction = Assert.Single(interactionService.GetCurrentInteractions());
        var (fileId, _) = fileUploadStore.CreateEntry("spoofed.txt", interaction.InteractionId, textInput.Name);
        var value = $"[{{\"Id\":\"{fileId}\",\"Name\":\"spoofed.txt\"}}]";
 
        var request = new WatchInteractionsRequestUpdate
        {
            InteractionId = interaction.InteractionId,
            InputsDialog = new InteractionInputsDialog()
        };
        request.InputsDialog.InputItems.Add(new Aspire.DashboardService.Proto.V1.InteractionInput
        {
            Name = textInput.Name,
            InputType = Aspire.DashboardService.Proto.V1.InputType.File,
            Value = value
        });
 
        await dashboardServiceData.SendInteractionRequestAsync(request, CancellationToken.None);
        var result = await resultTask;
        var resultInputs = Assert.IsType<InteractionInputCollection>(result.Data);
 
        Assert.Empty(resultInputs[textInput.Name].GetFiles());
        Assert.Equal(value, resultInputs[textInput.Name].Value);
    }
 
    [Fact]
    public async Task SendInteractionRequestAsync_UsesAuthoritativeFilesAndDisposeDeletesUploads()
    {
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        var interactionService = new InteractionService(
            NullLogger<InteractionService>.Instance,
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            fileUploadStore);
        using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore);
        var input = new InteractionInput { Name = "File", InputType = InputType.File, Required = true, AllowMultipleFiles = true };
        var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input);
        var interaction = Assert.Single(interactionService.GetCurrentInteractions());
        var (fileId, filePath) = fileUploadStore.CreateEntry("document.txt", interaction.InteractionId, input.Name);
        var (secondFileId, secondFilePath) = fileUploadStore.CreateEntry("second.txt", interaction.InteractionId, input.Name);
        await File.WriteAllTextAsync(filePath, "content");
        await File.WriteAllTextAsync(secondFilePath, "second content");
        fileUploadStore.CompleteUpload(interaction.InteractionId, fileId);
        fileUploadStore.CompleteUpload(interaction.InteractionId, secondFileId);
        var request = new WatchInteractionsRequestUpdate
        {
            InteractionId = interaction.InteractionId,
            InputsDialog = new InteractionInputsDialog()
        };
        request.InputsDialog.InputItems.Add(new Aspire.DashboardService.Proto.V1.InteractionInput
        {
            Name = input.Name,
            InputType = Aspire.DashboardService.Proto.V1.InputType.File,
            Value = $"[{{\"Id\":\"{fileId}\",\"Name\":\"spoofed.txt\"}},{{\"Id\":\"{secondFileId}\",\"Name\":\"also-spoofed.txt\"}}]"
        });
        await dashboardServiceData.SendInteractionRequestAsync(request, CancellationToken.None);
        var result = await resultTask;
        var resultInput = Assert.IsType<InteractionInput>(result.Data);
        var files = resultInput.GetFiles();
        Assert.Equal(2, files.Count);
        var file = files[0];
        var secondFile = files[1];
        Assert.Equal(filePath, file.FilePath);
        Assert.Equal("document.txt", file.Name);
        Assert.Equal(secondFilePath, secondFile.FilePath);
        Assert.Equal("second.txt", secondFile.Name);
        Assert.True(File.Exists(filePath));
        Assert.True(File.Exists(secondFilePath));
        Assert.Equal("content", Encoding.UTF8.GetString(await file.ReadAllBytesAsync()));
        await using var stream = file.OpenRead();
        using var reader = new StreamReader(stream);
        Assert.Equal("content", await reader.ReadToEndAsync());
        var readAllBytesTask = file.ReadAllBytesAsync();
 
        files.Dispose();
        files.Dispose();
 
        Assert.False(File.Exists(filePath));
        Assert.False(File.Exists(secondFilePath));
        Assert.Equal("content", Encoding.UTF8.GetString(await readAllBytesTask));
        Assert.Null(fileUploadStore.GetFilePath(fileId, interaction.InteractionId, input.Name));
        Assert.Null(fileUploadStore.GetFilePath(secondFileId, interaction.InteractionId, input.Name));
        Assert.Throws<ObjectDisposedException>(file.OpenRead);
        await Assert.ThrowsAsync<ObjectDisposedException>(ReadAllBytesAfterDisposeAsync);
 
        Task ReadAllBytesAfterDisposeAsync() => file.ReadAllBytesAsync();
    }
 
    [Fact]
    public async Task SendInteractionRequestAsync_MismatchedFiles_Throws()
    {
        var fileUploadStore = new TestInteractionFileUploadStore();
        var interactionService = new InteractionService(
            NullLogger<InteractionService>.Instance,
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            fileUploadStore);
        using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore);
        var input = new InteractionInput { Name = "File", InputType = InputType.File };
        var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input);
        var interaction = Assert.Single(interactionService.GetCurrentInteractions());
        var (fileId, _) = fileUploadStore.CreateEntry("document.txt", interaction.InteractionId, input.Name);
        fileUploadStore.CompleteUpload(interaction.InteractionId, fileId);
        var request = new WatchInteractionsRequestUpdate
        {
            InteractionId = interaction.InteractionId,
            InputsDialog = new InteractionInputsDialog()
        };
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            dashboardServiceData.SendInteractionRequestAsync(request, CancellationToken.None));
 
        Assert.Equal("Submitted files for input 'File' do not match the completed uploads.", exception.Message);
        await dashboardServiceData.SendInteractionRequestAsync(
            new WatchInteractionsRequestUpdate
            {
                InteractionId = interaction.InteractionId,
                Complete = new InteractionComplete()
            },
            CancellationToken.None);
        Assert.True((await resultTask).Canceled);
    }
 
    [Fact]
    public async Task UploadFile_ThenResolveFiles_ResolvesCorrectly()
    {
        var dashboardServiceData = CreateDashboardServiceData();
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("CertInput", InteractionHelpers.MaxFileCount)]);
        var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
 
        // Upload a file
        var data = Encoding.UTF8.GetBytes("certificate-content");
        var context = TestServerCallContext.Create();
        var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
        requestStream.AddMessage(new UploadFileChunk { FileName = "cert.pem", Data = ByteString.CopyFrom(data), InteractionId = 1, InputName = "CertInput" });
        requestStream.Complete();
 
        var uploadResponse = await dashboardService.UploadFile(requestStream, context);
 
        var json = $"[{{\"Id\":\"{uploadResponse.FileId}\"}}]";
        var resolvedFiles = fileUploadStore.GetCompletedFiles(1, "CertInput");
        InteractionFileUploadStore.ValidateFileReferences(json, "CertInput", resolvedFiles);
 
        Assert.NotNull(resolvedFiles);
        var file = Assert.Single(resolvedFiles);
        Assert.Equal(uploadResponse.FileId, file.Id);
        Assert.Equal("cert.pem", file.Name);
        Assert.True(File.Exists(file.FilePath));
 
        // Verify the file content was written correctly
        var content = await File.ReadAllBytesAsync(file.FilePath);
        Assert.Equal(data, content);
    }
 
    [Fact]
    public void ResolveFiles_UnknownInteraction_ReturnsNull()
    {
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        var result = fileUploadStore.GetCompletedFiles(1, "TestInput");
        InteractionFileUploadStore.ValidateFileReferences(jsonValue: null, "TestInput", result);
 
        Assert.Empty(result);
    }
 
    [Fact]
    public void ResolveFiles_UnknownInput_ReturnsNull()
    {
        using var fileSystemService = new TestFileSystemService();
        using var fileUploadStore = CreateFileUploadStore(fileSystemService);
        fileUploadStore.StartInteraction(1, [("File", 1)]);
        var (fileId, _) = fileUploadStore.CreateEntry("file.txt", 1, "File");
        fileUploadStore.CompleteUpload(1, fileId);
 
        var result = fileUploadStore.GetCompletedFiles(1, "OtherFile");
        InteractionFileUploadStore.ValidateFileReferences(jsonValue: null, "OtherFile", result);
 
        Assert.Empty(result);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WatchTerminals_StalledWriteRecoversInventoryAndPendingActivation(bool removeActivatedTerminal)
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var terminal = Assert.IsType<Hex1bAspireTerminal>(terminalService.CreateTerminal(new TerminalLaunchOptions
        {
            Title = "Before",
            Placement = TerminalPlacement.Dock,
            Executable = "bash"
        }).Backend);
        terminal.Show();
        var service = CreateDashboardService(serviceData, terminalService: terminalService);
        using var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var responses = new TestServerStreamWriter<WatchTerminalsUpdate>(context)
        {
            BeforeWriteAsync = (_, cancellationToken) =>
            {
                writing.TrySetResult();
                return resume.Task.WaitAsync(cancellationToken);
            }
        };
        var watch = service.WatchTerminals(new(), responses, context);
        try
        {
            await writing.Task.DefaultTimeout();
            terminal.Show();
            for (var i = 1; i < TerminalService.DefaultDockUpdateBufferCapacity; i++)
            {
                terminal.Retitle($"Revision {i}");
            }
            if (removeActivatedTerminal)
            {
                await terminal.DisposeAsync();
            }
            else
            {
                terminal.Retitle("Recovered");
            }
            resume.SetResult();
 
            var initial = await responses.ReadNextAsync().DefaultTimeout();
            Assert.Equal("Before", Assert.Single(initial.Snapshot.Terminals).Title);
            Assert.Equal(string.Empty, initial.Snapshot.ActivatedTerminalId);
 
            var recovery = WatchTerminalsUpdate.Parser.ParseFrom((await responses.ReadNextAsync().DefaultTimeout()).ToByteArray());
            Assert.Equal(terminal.Id, recovery.Snapshot.ActivatedTerminalId);
            if (removeActivatedTerminal)
            {
                Assert.Empty(recovery.Snapshot.Terminals);
            }
            else
            {
                var descriptor = Assert.Single(recovery.Snapshot.Terminals);
                Assert.Equal(terminal.Id, descriptor.TerminalId);
                Assert.Equal("Recovered", descriptor.Title);
            }
 
            var added = terminalService.CreateTerminal(new TerminalLaunchOptions
            {
                Title = "After recovery",
                Placement = TerminalPlacement.Dock,
                Executable = "bash"
            });
            var change = await responses.ReadNextAsync().DefaultTimeout();
            Assert.Equal(Aspire.DashboardService.Proto.V1.TerminalChangeType.Added, change.Change.ChangeType);
            Assert.Equal(added.Id, change.Change.Terminal.TerminalId);
            Assert.Equal("After recovery", change.Change.Terminal.Title);
        }
        finally
        {
            await cts.CancelAsync();
            await watch.DefaultTimeout();
        }
    }
 
    [Fact]
    public async Task WatchTerminals_CancellationDuringStalledWriteCompletesWatch()
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var service = CreateDashboardService(serviceData, terminalService: terminalService);
        using var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var writing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var responses = new TestServerStreamWriter<WatchTerminalsUpdate>(context)
        {
            BeforeWriteAsync = (_, cancellationToken) =>
            {
                writing.TrySetResult();
                return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
            }
        };
        var watch = service.WatchTerminals(new(), responses, context);
        try
        {
            await writing.Task.DefaultTimeout();
        }
        finally
        {
            await cts.CancelAsync();
            await watch.DefaultTimeout();
        }
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AttachTerminal_WorkloadEndedReportsStatusWithoutHmpHandshake(bool endedBeforeAttach)
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var service = CreateDashboardService(serviceData, terminalService: terminalService);
        var output = new Pipe();
        await using var reader = output.Reader.AsStream();
        await using var writer = output.Writer.AsStream();
        var workload = new StreamWorkloadAdapter(reader, Stream.Null);
        await using var terminal = terminalService.CreateTerminal("Ended", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24);
        terminal.Start();
        await writer.WriteAsync("ready\r\n"u8.ToArray());
        await terminal.WaitForTextAsync("ready").DefaultTimeout();
 
        if (endedBeforeAttach)
        {
            workload.SignalDisconnected();
            await Assert.IsType<Hex1bAspireTerminal>(terminal.Backend).WorkloadEnded.DefaultTimeout();
        }
 
        using var cts = new CancellationTokenSource();
        var context = TestServerCallContext.Create(cancellationToken: cts.Token);
        var requests = new TestAsyncStreamReader<TerminalClientFrame>(context);
        var responses = new TestServerStreamWriter<TerminalServerFrame>(context);
        requests.AddMessage(new TerminalClientFrame { TerminalId = terminal.Id });
        var attachment = service.AttachTerminal(requests, responses, context);
        try
        {
            if (!endedBeforeAttach)
            {
                workload.SignalDisconnected();
            }
 
            var status = await responses.ReadNextAsync().DefaultTimeout();
            Assert.True(status.Ended);
            Assert.True(status.Data.IsEmpty);
            Assert.False(attachment.IsCompleted);
            Assert.True(terminalService.TryGetTerminal(terminal.Id, out _));
        }
        finally
        {
            await cts.CancelAsync();
            await attachment.DefaultTimeout();
        }
    }
 
    [Fact]
    public async Task CloseTerminal_UnknownId_Succeeds()
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var service = CreateDashboardService(serviceData, terminalService: terminalService);
 
        var response = await service.CloseTerminal(
            new CloseTerminalRequest { TerminalId = "unknown" }, TestServerCallContext.Create()).DefaultTimeout();
 
        Assert.NotNull(response);
    }
 
    [Fact]
    public async Task CloseTerminal_ResourceOwnedTerminal_RejectsWithoutDisposingSharedHandle()
    {
        using var fileSystem = new TestFileSystemService();
        using var directory = fileSystem.TempDirectory.CreateTempSubdirectory();
        var resource = new TestResource("myapp");
        var layout = new TerminalHostLayout(
            replicaId: "test0000000",
            parentReplicaIndex: 0,
            producerUdsPath: Path.Combine(directory.Path, "producer.sock"),
            consumerUdsPath: Path.Combine(directory.Path, "consumer.sock"),
            controlUdsPath: Path.Combine(directory.Path, "control.sock"),
            metadataPath: Path.Combine(directory.Path, "metadata.json"));
        var annotation = new TerminalAnnotation(new TerminalOptions());
        annotation.Initialize([new TerminalHostResource("myapp-terminalhost-0", resource, layout)]);
        resource.Annotations.Add(annotation);
 
        await using var catalog = new ResourceTerminalCatalog(new DistributedApplicationModel([resource]), NullLogger.Instance);
        await using var terminalService = TestTerminalService.Create();
        terminalService.ResourceTerminals = catalog;
        using var serviceData = CreateDashboardServiceData();
        var service = CreateDashboardService(serviceData, terminalService: terminalService);
        Assert.True(terminalService.TryGetTerminal(ResourceTerminalCatalog.BuildId(resource.Name, 0), out var terminal));
        var backend = Assert.IsType<ResourceAspireTerminal>(terminal.Backend);
 
        var exception = await Assert.ThrowsAsync<RpcException>(() => service.CloseTerminal(
            new CloseTerminalRequest { TerminalId = terminal.Id }, TestServerCallContext.Create())).DefaultTimeout();
 
        Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
        Assert.False(backend.IsDisposed);
        Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered));
        Assert.Same(terminal, registered);
    }
 
    [Theory]
    [InlineData(TerminalPlacement.Dialog, false)]
    [InlineData(TerminalPlacement.Dialog, true)]
    [InlineData(TerminalPlacement.None, false)]
    [InlineData(TerminalPlacement.None, true)]
    public async Task CloseTerminal_NonDockAppHostTerminal_RejectsWithoutDisposingCallerOwnedHandle(TerminalPlacement placement, bool started)
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var service = CreateDashboardService(serviceData, terminalService: terminalService);
        var output = new Pipe();
        await using var reader = output.Reader.AsStream();
        await using var writer = output.Writer.AsStream();
        await using var terminal = terminalService.CreateTerminal("Caller-owned", placement,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(reader, Stream.Null)), 80, 24);
        if (started)
        {
            terminal.Start();
        }
 
        var exception = await Assert.ThrowsAsync<RpcException>(() => service.CloseTerminal(
            new CloseTerminalRequest { TerminalId = terminal.Id }, TestServerCallContext.Create())).DefaultTimeout();
 
        Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
        Assert.Equal("Only AppHost-owned dock terminals can be closed from the dashboard.", exception.Status.Detail);
        Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered));
        Assert.Same(terminal, registered);
 
        terminal.Start();
        await writer.WriteAsync("still usable"u8.ToArray());
        await terminal.WaitForTextAsync("still usable").DefaultTimeout();
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task CloseTerminal_DisposesTerminal_AndRepeatedCloseSucceeds(bool started)
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var service = CreateDashboardService(serviceData, terminalService: terminalService);
        var output = new Pipe();
        await using var reader = output.Reader.AsStream();
        await using var writer = output.Writer.AsStream();
        await using var terminal = terminalService.CreateTerminal("Close", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(reader, Stream.Null)), 80, 24);
        if (started)
        {
            terminal.Start();
        }
 
        var request = new CloseTerminalRequest { TerminalId = terminal.Id };
        var response = await service.CloseTerminal(request, TestServerCallContext.Create()).DefaultTimeout();
 
        Assert.NotNull(response);
        Assert.False(terminalService.TryGetTerminal(terminal.Id, out _));
        Assert.True(terminal.DisposeAsync().AsTask().IsCompletedSuccessfully);
        Assert.NotNull(await service.CloseTerminal(request, TestServerCallContext.Create()).DefaultTimeout());
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task CloseTerminal_PendingTransportCleanup_TimeoutOrCancellationDoesNotReleaseTransport(bool cancelRpc)
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var service = CreateDashboardService(serviceData, terminalService: terminalService);
        var output = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        await using var terminal = terminalService.CreateTerminal("Closing", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24);
        var (serverStream, clientStream) = TestDuplexStream.CreatePair();
        using var serverOwner = serverStream;
        using var clientOwner = clientStream;
        using var gated = new GatedTerminalWriteStream(serverStream);
        using var clientCts = new CancellationTokenSource();
        using var rpcCts = new CancellationTokenSource();
        await using var client = Hex1bTerminal.CreateBuilder().WithHeadless().WithHmp1Stream(clientStream).Build();
        var attachment = terminalService.AttachAsync(terminal.Id, gated, _ => Task.CompletedTask, CancellationToken.None);
        var run = client.RunAsync(clientCts.Token);
 
        try
        {
            await gated.WriteStarted.DefaultTimeout();
            var stopwatch = Stopwatch.StartNew();
            var close = service.CloseTerminal(
                new CloseTerminalRequest { TerminalId = terminal.Id },
                TestServerCallContext.Create(cancellationToken: rpcCts.Token));
            await gated.WriteCancelled.DefaultTimeout();
            var cleanup = terminal.DisposeAsync().AsTask();
 
            if (cancelRpc)
            {
                await rpcCts.CancelAsync();
                var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => close).DefaultTimeout();
                Assert.Equal(rpcCts.Token, exception.CancellationToken);
            }
            else
            {
                Assert.Equal(10, DashboardServiceImpl.CloseTerminalTimeoutSeconds);
                var exception = await Assert.ThrowsAsync<RpcException>(() => close).TimeoutAfter(TimeSpan.FromSeconds(30));
                Assert.Equal(StatusCode.DeadlineExceeded, exception.StatusCode);
                // Allow timer granularity without accepting a shorter production timeout.
                Assert.True(stopwatch.Elapsed >= TimeSpan.FromSeconds(9.9), $"Close timed out after {stopwatch.Elapsed}.");
            }
 
            Assert.False(cleanup.IsCompleted);
            Assert.False(attachment.IsCompleted);
            Assert.False(terminalService.TryGetTerminal(terminal.Id, out _));
            var shutdown = terminalService.DisposeAsync().AsTask();
            Assert.False(shutdown.IsCompleted);
            gated.ReleaseWrite();
            await cleanup.DefaultTimeout();
            await shutdown.DefaultTimeout();
            await attachment.DefaultTimeout();
        }
        finally
        {
            gated.ReleaseWrite();
            await terminal.DisposeAsync().AsTask().DefaultTimeout();
            await attachment.DefaultTimeout();
            await clientCts.CancelAsync();
            try
            {
                await run.DefaultTimeout();
            }
            catch (OperationCanceledException) when (clientCts.IsCancellationRequested)
            {
            }
        }
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(true, true)]
    public async Task CloseTerminal_BlockingDisposal_TimeoutOrCancellationObservesLateFailure(bool fail, bool cancelRpc)
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var sink = new TestSink();
        var logs = Channel.CreateUnbounded<WriteContext>();
        sink.MessageLogged += log => logs.Writer.TryWrite(log);
        var logger = new TestLogger<DashboardServiceImpl>(new TestLoggerFactory(sink, enabled: true));
        var service = CreateDashboardService(serviceData, logger: logger, terminalService: terminalService);
        using var rpcCts = new CancellationTokenSource();
        var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var terminal = new AspireTerminal(new TestTerminalBackend("blocking")
        {
            OnDispose = () =>
            {
                started.TrySetResult();
                try
                {
                    // A synchronous cancellation callback can block before DisposeAsync even returns a task.
                    release.Task.GetAwaiter().GetResult();
                    return ValueTask.CompletedTask;
                }
                finally
                {
                    completed.TrySetResult();
                }
            }
        });
 
        try
        {
            // Keep the test's gate releasable even if disposal regresses to blocking the RPC synchronously.
            var close = Task.Run(() => service.CloseTerminalAsync(terminal, rpcCts.Token));
            await started.Task.DefaultTimeout();
            if (cancelRpc)
            {
                await rpcCts.CancelAsync();
                var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => close).DefaultTimeout();
                Assert.Equal(rpcCts.Token, exception.CancellationToken);
            }
            else
            {
                var exception = await Assert.ThrowsAsync<RpcException>(() => close).TimeoutAfter(TimeSpan.FromSeconds(30));
                Assert.Equal(StatusCode.DeadlineExceeded, exception.StatusCode);
            }
            Assert.False(completed.Task.IsCompleted);
 
            var failure = new InvalidOperationException("Disposal callback failed.");
            if (fail)
            {
                release.TrySetException(failure);
            }
            else
            {
                release.TrySetResult();
            }
            await completed.Task.DefaultTimeout();
 
            if (fail)
            {
                var log = await logs.Reader.ReadAsync().AsTask().DefaultTimeout();
                Assert.Equal(LogLevel.Error, log.LogLevel);
                Assert.Equal($"Failed to dispose terminal {terminal.Id}.", log.Message);
                Assert.Same(failure, log.Exception);
            }
        }
        finally
        {
            release.TrySetResult();
            await completed.Task.DefaultTimeout();
        }
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task CloseTerminal_DisposalFailure_IsLoggedAndPropagated(bool timeoutException, bool synchronous)
    {
        using var serviceData = CreateDashboardServiceData();
        await using var terminalService = TestTerminalService.Create();
        var sink = new TestSink();
        var logs = Channel.CreateUnbounded<WriteContext>();
        sink.MessageLogged += log => logs.Writer.TryWrite(log);
        var logger = new TestLogger<DashboardServiceImpl>(new TestLoggerFactory(sink, enabled: true));
        var service = CreateDashboardService(serviceData, logger: logger, terminalService: terminalService);
        Exception failure = timeoutException ? new TimeoutException("Workload timeout.") : new InvalidOperationException("Disposal failed.");
        var terminal = new AspireTerminal(new TestTerminalBackend("failing")
        {
            OnDispose = () => synchronous ? throw failure : ValueTask.FromException(failure)
        });
 
        var exception = await Record.ExceptionAsync(() => service.CloseTerminalAsync(terminal, CancellationToken.None)).DefaultTimeout();
 
        Assert.Same(failure, exception);
        var log = await logs.Reader.ReadAsync().AsTask().DefaultTimeout();
        Assert.Equal(LogLevel.Error, log.LogLevel);
        Assert.Equal($"Failed to dispose terminal {terminal.Id}.", log.Message);
        Assert.Same(failure, log.Exception);
    }
 
    private static DashboardServiceImpl CreateDashboardService(
        DashboardServiceData dashboardServiceData,
        IHostEnvironment? hostEnvironment = null,
        IConfiguration? configuration = null,
        ILogger<DashboardServiceImpl>? logger = null,
        IInteractionFileUploadStore? fileUploadStore = null,
                        TerminalService? terminalService = null)
    {
        return new DashboardServiceImpl(
            dashboardServiceData,
            hostEnvironment ?? new TestHostEnvironment(),
            new TestHostApplicationLifetime(),
            configuration ?? new ConfigurationBuilder().Build(),
            logger ?? NullLogger<DashboardServiceImpl>.Instance,
            fileUploadStore ?? new TestInteractionFileUploadStore(),
            terminalService ?? TestTerminalService.Create());
    }
 
    private static DashboardServiceData CreateDashboardServiceData(
        ResourceLoggerService? resourceLoggerService = null,
        ResourceNotificationService? resourceNotificationService = null,
        ILoggerFactory? loggerFactory = null,
        InteractionService? interactionService = null,
        IInteractionFileUploadStore? fileUploadStore = null)
    {
        resourceLoggerService ??= new ResourceLoggerService();
        loggerFactory ??= NullLoggerFactory.Instance;
        resourceNotificationService ??= CreateResourceNotificationService(resourceLoggerService);
        fileUploadStore ??= new TestInteractionFileUploadStore();
        interactionService ??= new InteractionService(
            NullLogger<InteractionService>.Instance,
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            fileUploadStore);
 
        return new DashboardServiceData(
            resourceNotificationService,
            resourceLoggerService,
            loggerFactory.CreateLogger<DashboardServiceData>(),
            new ResourceCommandService(resourceNotificationService, resourceLoggerService, new ServiceCollection().BuildServiceProvider()),
            interactionService,
            fileUploadStore);
    }
 
    private static ResourceNotificationService CreateResourceNotificationService(ResourceLoggerService resourceLoggerService)
    {
        return new ResourceNotificationService(NullLogger<ResourceNotificationService>.Instance, new TestHostApplicationLifetime(), new ServiceCollection().BuildServiceProvider(), resourceLoggerService);
    }
 
    private sealed class TestHostEnvironment : IHostEnvironment
    {
        public string ApplicationName { get; set; } = default!;
        public IFileProvider ContentRootFileProvider { get; set; } = default!;
        public string ContentRootPath { get; set; } = default!;
        public string EnvironmentName { get; set; } = default!;
    }
 
    private sealed class TestResource(string name) : Resource(name)
    {
    }
 
    private static async Task CancelTokenAndAwaitTask(CancellationTokenSource cts, Task task)
    {
        await cts.CancelAsync();
 
        try
        {
            await task;
        }
        catch (OperationCanceledException)
        {
            // Ok if this error is thrown.
        }
    }
 
    private static InteractionFileUploadStore CreateFileUploadStore(IFileSystemService fileSystemService) =>
        new(fileSystemService, NullLogger<InteractionFileUploadStore>.Instance);
}