File: Model\ResourceMenuBuilderTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Components.Resize;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Dashboard.Tests.TelemetryRepositoryTests;
using Aspire.Tests.Shared;
using Aspire.Tests.Shared.DashboardModel;
using Aspire.Tests.Shared.Telemetry;
using Google.Protobuf.Collections;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.FluentUI.AspNetCore.Components;
using OpenTelemetry.Proto.Trace.V1;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Model;
 
public sealed class ResourceMenuBuilderTests : IDisposable
{
    private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
    private readonly IconResolver _iconResolver = new IconResolver(NullLogger<IconResolver>.Instance);
    private readonly DashboardDialogService _dialogService;
    private readonly List<DashboardDataSource> _dataSources = [];
    private readonly List<DashboardDataSourcePool> _databasePools = [];
    private readonly List<DirectoryInfo> _temporaryDirectories = [];
 
    public ResourceMenuBuilderTests()
    {
        var dimensionManager = new DimensionManager();
        dimensionManager.InvokeOnViewportInformationChanged(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
        _dialogService = new DashboardDialogService(
            new TestDialogService(),
            new TestStringLocalizer<Dialogs>(),
            dimensionManager);
    }
 
    private ResourceMenuBuilder CreateResourceMenuBuilder(
        SqliteTelemetryRepository repository,
        IDashboardClient? dashboardClient = null)
    {
        dashboardClient ??= new TestDashboardClient();
        var temporaryDirectory = Directory.CreateTempSubdirectory();
        _temporaryDirectories.Add(temporaryDirectory);
        var runStore = new TestDashboardRunStore(databasePath: Path.Combine(temporaryDirectory.FullName, "dashboard.db"));
        var dataSourcePool = TestDashboardDataSource.CreatePool(repository, dashboardClient, runStore);
        var dataSource = TestDashboardDataSource.Create(runStore, dataSourcePool);
        _databasePools.Add(dataSourcePool);
        _dataSources.Add(dataSource);
 
        return new ResourceMenuBuilder(
            new TestNavigationManager(),
            dataSource,
            new TestStringLocalizer<ControlsStrings>(),
            new TestStringLocalizer<Resources.Resources>(),
            _iconResolver,
            _dialogService,
            dashboardClient);
    }
 
    public void Dispose()
    {
        foreach (var dataSource in _dataSources)
        {
            dataSource.Dispose();
        }
 
        foreach (var databasePool in _databasePools)
        {
            databasePool.Dispose();
        }
 
        foreach (var temporaryDirectory in _temporaryDirectories)
        {
            temporaryDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void AddMenuItems_NoTelemetry_NoTelemetryItems()
    {
        // Arrange
        var resource = ModelTestHelpers.CreateResource();
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository();
        var repository = repositoryContext.Repository;
        var resourceMenuBuilder = CreateResourceMenuBuilder(repository);
 
        // Act
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: true,
            showConsoleLogsItem: true,
            showUrls: true);
 
        // Assert
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ActionViewDetailsText", e.Text),
            e => Assert.Equal("Localized:ResourceActionConsoleLogsText", e.Text),
            e => Assert.Equal("Localized:ViewJson", e.Text));
    }
 
    [Fact]
    public async Task AddMenuItems_UninstrumentedPeer_TraceItem()
    {
        // Arrange
        var resource = ModelTestHelpers.CreateResource(resourceName: "test-abc");
        var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: attributes => (resource.Name, resource));
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository(outgoingPeerResolvers: [outgoingPeerResolver]);
        var repository = repositoryContext.Repository;
        var addContext = new AddContext();
        await repository.AddTracesAsync(addContext, new RepeatedField<ResourceSpans>()
        {
            new ResourceSpans
            {
                Resource = TelemetryTestHelpers.CreateResource(name: "source", instanceId: "abc"),
                ScopeSpans =
                {
                    new ScopeSpans
                    {
                        Scope = TelemetryTestHelpers.CreateScope(),
                        Spans =
                        {
                            TelemetryTestHelpers.CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10), attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "value-1")], kind: Span.Types.SpanKind.Client),
                            TelemetryTestHelpers.CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1", attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "value-2")], kind: Span.Types.SpanKind.Client)
                        }
                    }
                }
            }
        });
 
        var resourceMenuBuilder = CreateResourceMenuBuilder(repository);
 
        // Act
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: true,
            showConsoleLogsItem: true,
            showUrls: true);
 
        // Assert
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ActionViewDetailsText", e.Text),
            e => Assert.Equal("Localized:ResourceActionConsoleLogsText", e.Text),
            e => Assert.Equal("Localized:ViewJson", e.Text),
            e => Assert.True(e.IsDivider),
            e => Assert.Equal("Localized:ResourceActionTracesText", e.Text));
    }
 
    [Fact]
    public async Task AddMenuItems_HasTelemetry_TelemetryItems()
    {
        // Arrange
        var resource = ModelTestHelpers.CreateResource(resourceName: "test-abc");
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository();
        var repository = repositoryContext.Repository;
        var addContext = new AddContext();
        await repository.AddTracesAsync(addContext, new RepeatedField<ResourceSpans>()
        {
            new ResourceSpans
            {
                Resource = TelemetryTestHelpers.CreateResource(name: "test", instanceId: "abc"),
                ScopeSpans =
                {
                    new ScopeSpans
                    {
                        Scope = TelemetryTestHelpers.CreateScope(),
                        Spans =
                        {
                            TelemetryTestHelpers.CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10))
                        }
                    }
                }
            }
        });
 
        var resourceMenuBuilder = CreateResourceMenuBuilder(repository);
 
        // Act
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: true,
            showConsoleLogsItem: true,
            showUrls: true);
 
        // Assert
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ActionViewDetailsText", e.Text),
            e => Assert.Equal("Localized:ResourceActionConsoleLogsText", e.Text),
            e => Assert.Equal("Localized:ViewJson", e.Text),
            e => Assert.True(e.IsDivider),
            e => Assert.Equal("Localized:ResourceActionStructuredLogsText", e.Text),
            e => Assert.Equal("Localized:ResourceActionTracesText", e.Text),
            e => Assert.Equal("Localized:ResourceActionMetricsText", e.Text));
    }
 
    [Fact]
    public void AddMenuItems_WithFromSpecEnvVars_ExportEnvMenuItemShown()
    {
        // Arrange
        var resource = ModelTestHelpers.CreateResource(
            environment: [
                new EnvironmentVariableViewModel("SPEC_VAR", "spec-value", fromSpec: true),
                new EnvironmentVariableViewModel("RUNTIME_VAR", "runtime-value", fromSpec: false)
            ]);
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository();
        var repository = repositoryContext.Repository;
        var resourceMenuBuilder = CreateResourceMenuBuilder(repository);
 
        // Act
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: true,
            showConsoleLogsItem: true,
            showUrls: true);
 
        // Assert
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ActionViewDetailsText", e.Text),
            e => Assert.Equal("Localized:ResourceActionConsoleLogsText", e.Text),
            e => Assert.Equal("Localized:ViewJson", e.Text),
            e => Assert.Equal("Localized:ExportEnv", e.Text));
    }
 
    [Fact]
    public void AddMenuItems_WithoutFromSpecEnvVars_ExportEnvMenuItemNotShown()
    {
        // Arrange - only runtime env vars (fromSpec: false), no spec env vars
        var resource = ModelTestHelpers.CreateResource(
            environment: [
                new EnvironmentVariableViewModel("RUNTIME_VAR1", "value1", fromSpec: false),
                new EnvironmentVariableViewModel("RUNTIME_VAR2", "value2", fromSpec: false)
            ]);
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository();
        var repository = repositoryContext.Repository;
        var resourceMenuBuilder = CreateResourceMenuBuilder(repository);
 
        // Act
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: true,
            showConsoleLogsItem: true,
            showUrls: true);
 
        // Assert - ExportEnv should NOT be in the menu
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ActionViewDetailsText", e.Text),
            e => Assert.Equal("Localized:ResourceActionConsoleLogsText", e.Text),
            e => Assert.Equal("Localized:ViewJson", e.Text));
    }
 
    [Fact]
    public void AddMenuItems_IncludesStartCommandLikeOtherVisibleCommands()
    {
        var startCommand = new CommandViewModel(
            CommandViewModel.StartCommand,
            CommandViewModelState.Enabled,
            "Start",
            "Start the resource.",
            confirmationMessage: "",
            argumentInputs: [],
            isHighlighted: true,
            iconName: string.Empty,
            iconVariant: IconVariant.Regular);
        var stopCommand = new CommandViewModel(
            CommandViewModel.StopCommand,
            CommandViewModelState.Enabled,
            "Stop",
            "Stop the resource.",
            confirmationMessage: "",
            argumentInputs: [],
            isHighlighted: true,
            iconName: string.Empty,
            iconVariant: IconVariant.Regular);
        var resource = ModelTestHelpers.CreateResource(commands: [startCommand, stopCommand]);
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository();
        var repository = repositoryContext.Repository;
        var resourceMenuBuilder = CreateResourceMenuBuilder(repository);
 
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: false,
            showConsoleLogsItem: false,
            showUrls: false);
 
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ViewJson", e.Text),
            e => Assert.True(e.IsDivider),
            e => Assert.Equal("Start", e.Text),
            e => Assert.Equal("Stop", e.Text));
    }
 
    [Fact]
    public void AddMenuItems_ReadOnly_DisablesResourceCommands()
    {
        var command = new CommandViewModel(
            CommandViewModel.StartCommand,
            CommandViewModelState.Enabled,
            "Start",
            "Start the resource.",
            confirmationMessage: "",
            argumentInputs: [],
            isHighlighted: true,
            iconName: string.Empty,
            iconVariant: IconVariant.Regular);
        var resource = ModelTestHelpers.CreateResource(commands: [command]);
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository();
        var repository = repositoryContext.Repository;
        var resourceMenuBuilder = CreateResourceMenuBuilder(
            repository,
            new TestDashboardClient(isReadOnly: true));
 
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: false,
            showConsoleLogsItem: false,
            showUrls: false);
 
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ViewJson", e.Text),
            e => Assert.True(e.IsDivider),
            e =>
            {
                Assert.Equal("Start", e.Text);
                Assert.True(e.IsDisabled);
            });
    }
 
    [Fact]
    public void AddMenuItems_UnresolvableIconName_UsesFallbackIcon()
    {
        // A command with an icon name that doesn't map to any FluentUI icon should
        // get a QuestionCircle fallback instead of null, preventing the overflow issue in #18385.
        var command = new CommandViewModel(
            "test-command",
            CommandViewModelState.Enabled,
            "Test Command",
            "A command with a bad icon name.",
            confirmationMessage: "",
            argumentInputs: [],
            isHighlighted: false,
            iconName: "NotARealIconName",
            iconVariant: IconVariant.Regular);
        var resource = ModelTestHelpers.CreateResource(commands: [command]);
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository();
        var repository = repositoryContext.Repository;
        var resourceMenuBuilder = CreateResourceMenuBuilder(repository);
 
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: false,
            showConsoleLogsItem: false,
            showUrls: false);
 
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ViewJson", e.Text),
            e => Assert.True(e.IsDivider),
            e =>
            {
                Assert.Equal("Test Command", e.Text);
                Assert.IsType<Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.QuestionCircle>(e.Icon);
            });
    }
 
    [Fact]
    public void AddMenuItems_NoIconName_IconIsNull()
    {
        // A command with no icon name should have a null icon in the menu
        // (menu items have text labels so no fallback icon is needed).
        var command = new CommandViewModel(
            "test-command",
            CommandViewModelState.Enabled,
            "Test Command",
            "A command with no icon.",
            confirmationMessage: "",
            argumentInputs: [],
            isHighlighted: false,
            iconName: string.Empty,
            iconVariant: IconVariant.Regular);
        var resource = ModelTestHelpers.CreateResource(commands: [command]);
        using var repositoryContext = SqliteRepositoryTestHelpers.CreateTemporaryTelemetryRepository();
        var repository = repositoryContext.Repository;
        var resourceMenuBuilder = CreateResourceMenuBuilder(repository);
 
        var menuItems = new List<MenuButtonItem>();
        resourceMenuBuilder.AddMenuItems(
            menuItems,
            resource,
            new Dictionary<string, ResourceViewModel>(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
            EventCallback.Empty,
            EventCallback<CommandViewModel>.Empty,
            (_, _) => false,
            showViewDetails: false,
            showConsoleLogsItem: false,
            showUrls: false);
 
        Assert.Collection(menuItems,
            e => Assert.Equal("Localized:ViewJson", e.Text),
            e => Assert.True(e.IsDivider),
            e =>
            {
                Assert.Equal("Test Command", e.Text);
                Assert.Null(e.Icon);
            });
    }
 
    private sealed class TestNavigationManager : NavigationManager
    {
    }
}