File: Layout\ResourcesLayoutCloseTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj (Aspire.Dashboard.Components.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.Threading.Channels;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Components.Pages;
using Aspire.Dashboard.Components.Resize;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.BrowserStorage;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Dashboard.Utils;
using Aspire.Tests.Shared.DashboardModel;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
 
namespace Aspire.Dashboard.Components.Tests.Layout;
 
[UseCulture("en-US")]
public class ResourcesLayoutCloseTests : DashboardTestContext
{
    [Theory]
    [InlineData("/resources", "/resources", true)]
    [InlineData("/consolelogs", "/consolelogs", true)]
    [InlineData("/consolelogs?filter=example", "/consolelogs?filter=example", true)]
    [InlineData("/terminals", "/terminals", true)]
    [InlineData("/structuredlogs?logLevel=Error", "/structuredlogs?logLevel=Error", true)]
    [InlineData("/traces?type=server", "/traces?type=server", true)]
    [InlineData("/metrics?duration=30", "/metrics?duration=30", true)]
    [InlineData("/resources?resource=api&resource=cache", "/resources", true)]
    [InlineData("/resources/api", "/resources", true)]
    [InlineData("/resources/api?pane=tags", "/resources", true)]
    [InlineData("/resources/missing", "/resources", true)]
    [InlineData("/resources/key", "/resources", true)]
    [InlineData("/resources/api?terminal=true", "/resources", true)]
    [InlineData("/consolelogs/resource/api", "/consolelogs", true)]
    [InlineData("/terminals/resource/api", "/resources", true)]
    [InlineData("/structuredlogs/resource/api?logLevel=Error", "/structuredlogs?logLevel=Error", true)]
    [InlineData("/traces/resource/api?type=server", "/traces?type=server", true)]
    [InlineData("/metrics/resource/api?duration=30", "/metrics?duration=30", true)]
    [InlineData("/metrics/resource/api/meter/Test", "/metrics/resource/api", true)]
    [InlineData("/metrics/resource/api/meter/Test/instrument/Requests?duration=30", "/metrics/resource/api/meter/Test?duration=30", true)]
    [InlineData("/Metrics/Resource/api/Meter/Test/Instrument/Requests?duration=30", "/Metrics/Resource/api/Meter/Test?duration=30", true)]
    [InlineData("/Metrics/Resource/api/Meter/Test", "/metrics/resource/api", true)]
    [InlineData("/metrics/resource/api?meter=Test&instrument=Requests&duration=30", "/metrics/resource/api?meter=Test&duration=30", true)]
    [InlineData("/metrics/resource/api?meter=Test&duration=30", "/metrics/resource/api?duration=30", true)]
    [InlineData("/traces/detail/missing", "/traces", true)]
    [InlineData("/Traces/Detail/missing", "/traces", true)]
    [InlineData("/traces/detail/missing?resource=api&type=server&filters=example&spanId=span", "/traces/resource/api?resource=api&type=server&filters=example", true)]
    [InlineData("/traces/detail/missing?resource=api&resource=cache&type=server", "/traces?resource=api&resource=cache&type=server", true)]
    [InlineData("/traces/detail/missing?pane=telemetry", "/traces?pane=telemetry", true)]
    public async Task CloseDetail_NavigatesToParentAndShowsResourceList(string url, string expectedParent, bool isDesktop)
    {
        var viewport = new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var commandCount = 0;
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache"),
                ModelTestHelpers.CreateResource("key", resourceType: KnownResourceTypes.Parameter)],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>,
            executeResourceCommand: (_, _, _, _, _) =>
            {
                commandCount++;
                return Task.FromResult(new ResourceCommandResponseViewModel { Kind = ResourceCommandResponseKind.Succeeded });
            }));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        var close = Assert.Single(cut.FindComponents<Components.Controls.DetailPageCloseButton>());
        Assert.Equal(expectedParent, close.Instance.ParentUrl);
        Assert.Equal(ButtonAppearance.Default, close.FindComponent<FluentButton>().Instance.Appearance);
        await cut.Find(".detail-page-close").ClickAsync();
        cut.Render();
 
        Assert.Equal(expectedParent.TrimStart('/'), navigation.ToBaseRelativePath(navigation.Uri));
        Assert.Equal(isDesktop, cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
        Assert.Equal(!isDesktop, cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
        Assert.True(cut.Find(".resource-content").HasAttribute("inert"));
        Assert.Empty(cut.FindAll(".resource-pane-canvas-toggle"));
        Assert.Equal(0, commandCount);
        if (ResourcesLayout.ParseLocation(expectedParent).SelectedResourceNames.Count == 0)
        {
            Assert.Empty(cut.Instance.SelectedResourceNames);
        }
 
        if (!url.Contains("pane=telemetry", StringComparison.Ordinal))
        {
            cut.Find(".resource-row[data-resource-name='cache'] .resource-row-link").Click();
            cut.Render();
            await cut.InvokeAsync(() => cut.Instance.CompletePaneSelectionReveal(navigation.Uri));
            Assert.False(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
            Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
        }
    }
 
    [Theory]
    [InlineData("/resources", true)]
    [InlineData("/consolelogs", true)]
    [InlineData("/consolelogs?filter=example", true)]
    [InlineData("/structuredlogs", true)]
    [InlineData("/structuredlogs?logLevel=Error", true)]
    [InlineData("/traces", true)]
    [InlineData("/traces?type=server", true)]
    [InlineData("/metrics", true)]
    [InlineData("/metrics?duration=30", true)]
    public async Task SelectAll_ReopensTabAfterClosingContent(string url, bool isDesktop)
    {
        var viewport = new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        await cut.Find(".detail-page-close").ClickAsync();
        cut.Render();
        Assert.True(cut.Find(".resource-content").HasAttribute("inert"));
 
        await cut.Find(".resource-select-all-toggle").ClickAsync();
        cut.Render();
 
        Assert.Equal(url.TrimStart('/'), navigation.ToBaseRelativePath(navigation.Uri));
        Assert.Empty(cut.Instance.SelectedResourceNames);
        Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
        Assert.False(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
        Assert.False(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
        Assert.Single(cut.FindComponents<Components.Controls.DetailPageCloseButton>());
    }
 
    [Theory]
    [InlineData("/traces/detail/missing?resource=api&type=server")]
    [InlineData("/metrics/resource/api/meter/Test")]
    [InlineData("/metrics/resource/api?meter=Test&instrument=Requests&duration=30")]
    public async Task SelectedResource_ReopensParentFromFullCanvasWithoutClearingSelection(string url)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        await cut.Find(".detail-page-close").ClickAsync();
        cut.Render();
        var parent = navigation.Uri;
        Assert.Equal(["api"], cut.Instance.SelectedResourceNames);
        Assert.True(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
 
        await cut.Find(".resource-row[data-resource-name='api'] .resource-row-link").ClickAsync();
        cut.Render();
 
        Assert.Equal(parent, navigation.Uri);
        Assert.Equal(["api"], cut.Instance.SelectedResourceNames);
        Assert.False(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
        Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
 
        await cut.Find(".resource-row[data-resource-name='api'] .resource-row-link").ClickAsync();
        cut.Render();
        Assert.Empty(cut.Instance.SelectedResourceNames);
    }
 
    [Fact]
    public async Task CloseDetail_PreservesFullCanvasAcrossDestinationSessionStateRedirect()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>),
            configureServices: services =>
            {
                services.AddTransient<TracesViewModel>();
                services.AddSingleton<ISessionStorage>(new TestSessionStorage
                {
                    OnGetAsync = key => key == BrowserStorageKeys.TracesPageState
                        ? (true, new Traces.TracesPageState { SelectedSpanType = "server", Filters = [] })
                        : (false, null)
                });
            });
        FluentUISetupHelpers.SetupFluentList(this);
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo("/traces/resource/api");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        await cut.Find(".detail-page-close").ClickAsync();
        cut.Render(p => p.Add(layout => layout.Body, body =>
        {
            body.OpenComponent<Traces>(0);
            body.AddComponentParameter(1, nameof(Traces.ResourceName),
                ResourcesLayout.ParseLocation(navigation.ToBaseRelativePath(navigation.Uri)).RouteResourceName);
            body.CloseComponent();
        }));
        cut.WaitForAssertion(() => Assert.Equal("traces?type=server", navigation.ToBaseRelativePath(navigation.Uri)));
        cut.Render();
 
        Assert.Single(cut.FindComponents<Traces>());
        Assert.True(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
        Assert.True(cut.Find(".resource-content").HasAttribute("inert"));
        Assert.Empty(cut.Instance.SelectedResourceNames);
 
        await cut.Find(".resource-select-all-toggle").ClickAsync();
        cut.Render();
        Assert.Equal("traces?type=server", navigation.ToBaseRelativePath(navigation.Uri));
        Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
    }
 
    [Theory]
    [InlineData("/metrics?meter=Test", "metrics")]
    [InlineData("/metrics?meter=Test&instrument=Requests", "metrics?meter=Test")]
    public async Task CloseDetail_HistoryNavigationBackToQueryDetailOpensContent(string url, string expectedParent)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        await cut.Find(".detail-page-close").ClickAsync();
        cut.Render();
        Assert.Equal(expectedParent, navigation.ToBaseRelativePath(navigation.Uri));
        Assert.True(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
 
        // Browser back reaches the previous detail entry without the page state restoration marker.
        navigation.NavigateTo(url);
        cut.Render();
 
        Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
        Assert.False(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
    }
 
    [Theory]
    [InlineData("/structuredlogs")]
    [InlineData("/traces?resource=api")]
    [InlineData("/traces/detail/missing")]
    public async Task CloseDetail_UnrelatedNavigationOpensContent(string nextUrl)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo("/traces");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        await cut.Find(".detail-page-close").ClickAsync();
        cut.Render();
        Assert.True(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
 
        navigation.NavigateTo(nextUrl);
        cut.Render();
 
        Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
        Assert.False(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas"));
    }
 
    [Theory]
    [InlineData("/resources", "/resources")]
    [InlineData("/resources/api", "/resources")]
    [InlineData("/consolelogs", "/consolelogs")]
    [InlineData("/structuredlogs", "/structuredlogs")]
    [InlineData("/traces", "/traces")]
    [InlineData("/traces/detail/missing", "/traces")]
    [InlineData("/Traces/Detail/missing", "/traces")]
    [InlineData("/metrics", "/metrics")]
    [InlineData("/metrics?meter=Test&duration=30", "/metrics")]
    [InlineData("/metrics?meter=Test&instrument=Requests&duration=30", "/metrics")]
    [InlineData("/metrics/resource/api/meter/Test", "/metrics")]
    [InlineData("/metrics/resource/api/meter/Test/instrument/Requests", "/metrics")]
    [InlineData("/traces?type=server", "/traces?type=server")]
    [InlineData("/structuredlogs?logLevel=Error", "/structuredlogs?logLevel=Error")]
    [InlineData("/metrics?duration=30", "/metrics?duration=30")]
    public async Task ResourceDropdown_OpensAndClosesDrawerWithoutPageCloseButton(string url, string expectedDestination)
    {
        var viewport = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        Assert.Empty(cut.FindComponents<Components.Controls.DetailPageCloseButton>());
        Assert.True(cut.Find(".resource-pane").HasAttribute("hidden"));
        await cut.Find(".resource-picker").ClickAsync();
        Assert.True(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
        Assert.True(cut.Find(".resource-content").HasAttribute("inert"));
        Assert.False(cut.Find(".resource-pane").HasAttribute("hidden"));
 
        await cut.Find($"[aria-label='{Resources.Layout.ResourcePaneClose}']").ClickAsync();
        Assert.False(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
        Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
        Assert.Equal(url.TrimStart('/'), navigation.ToBaseRelativePath(navigation.Uri));
 
        await cut.Find(".resource-picker").ClickAsync();
        await cut.Find(".resource-select-all-toggle").ClickAsync();
        cut.Render();
        Assert.Equal(expectedDestination.TrimStart('/'), navigation.ToBaseRelativePath(navigation.Uri));
        Assert.Empty(cut.Instance.SelectedResourceNames);
        Assert.False(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
        Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
        Assert.Empty(cut.FindComponents<Components.Controls.DetailPageCloseButton>());
    }
 
    [Fact]
    public async Task ViewportChange_SwitchesBetweenCloseButtonAndResourceDropdown()
    {
        var desktop = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var mobile = desktop with { IsDesktop = false };
        ResourceSetupHelpers.SetupResourcesLayout(this, desktop, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        Services.GetRequiredService<NavigationManager>().NavigateTo("/resources/api");
        var cut = Render<CascadingValue<ViewportInformation>>(p => p.Add(v => v.Value, desktop).AddChildContent<ResourcesLayout>());
        cut.WaitForAssertion(() => Assert.True(cut.FindComponent<ResourcesLayout>().Instance.IsLoaded));
        Assert.Single(cut.FindComponents<Components.Controls.DetailPageCloseButton>());
 
        cut.Render(p => p.Add(v => v.Value, mobile).AddChildContent<ResourcesLayout>());
        Assert.Empty(cut.FindComponents<Components.Controls.DetailPageCloseButton>());
        await cut.Find(".resource-picker").ClickAsync();
        Assert.True(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
        cut.Render(p => p.Add(v => v.Value, desktop).AddChildContent<ResourcesLayout>());
        Assert.Single(cut.FindComponents<Components.Controls.DetailPageCloseButton>());
        Assert.False(cut.Find(".resource-content").HasAttribute("inert"));
    }
 
    [Fact]
    public void TraceLinks_CarrySelectionAndListFiltersToNewTabs()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo("/traces?resource=api&resource=cache&type=server&filters=example");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        navigation.NavigateTo(cut.Instance.GetTraceDetailUrl("trace", "span"));
        cut.Render();
 
        Assert.Equal(["api", "cache"], cut.Instance.SelectedResourceNames);
        Assert.Equal("/traces?resource=api&resource=cache&type=server&filters=example", cut.Instance.DetailParentUrl);
    }
 
    [Fact]
    public async Task TraceActionsViewDetails_CarriesSelectionAndListFilters()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo("/traces?resource=api&resource=cache&type=server&filters=example");
        var layout = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        layout.WaitForAssertion(() => Assert.True(layout.Instance.IsLoaded));
 
        var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() };
        var summary = new TraceSummary
        {
            TraceId = "trace",
            LastUpdatedTimestampTicks = 0,
            FullName = "api: GET /",
            StartTime = DateTime.UnixEpoch,
            Duration = TimeSpan.Zero,
            RootResource = new OtlpResource("api", "instance", uninstrumentedPeer: false, context),
            Resources = [],
            HasError = false,
            HasGenAI = false
        };
        var actions = Render<TraceActions>(p => p
            .Add(a => a.Summary, summary)
            .AddCascadingValue(layout.Instance));
 
        var viewDetails = Assert.Single(actions.FindComponent<AspireMenuButton>().Instance.ItemsProvider(),
            item => item.Text == Resources.ControlsStrings.ActionViewDetailsText);
        await actions.InvokeAsync(viewDetails.OnClick!);
 
        Assert.Equal(navigation.ToAbsoluteUri(layout.Instance.GetTraceDetailUrl("trace")).AbsoluteUri, navigation.Uri);
        Assert.Equal("traces/detail/trace?resource=api&resource=cache&type=server&filters=example", navigation.ToBaseRelativePath(navigation.Uri));
    }
}