File: Layout\ResourcesLayoutTests.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.Resize;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.BrowserStorage;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Dashboard.Utils;
using Aspire.Tests.Shared.DashboardModel;
using Bunit;
using Google.Protobuf.WellKnownTypes;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components;
using OpenTelemetry.Proto.Logs.V1;
using Xunit;
using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers;
 
namespace Aspire.Dashboard.Components.Tests.Layout;
 
[UseCulture("en-US")]
public class ResourcesLayoutTests : DashboardTestContext
{
    [Fact]
    public void AppHostTagsParticipateInGroupingWithoutUserTags()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var name = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag;
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", properties: new()
                {
                    [name] = new(name, Value.ForList(Value.ForString("Backend"), Value.ForString("API")), false, null, 0, null, false)
                }),
                ModelTestHelpers.CreateResource("cache")
            ], resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneGrouping ? (true, "Tags") : (false, null);
        Render<FluentToastProvider>();
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() => Assert.Equal(["Tags:API", "Tags:Backend", "Tags:"],
            cut.FindAll(".resource-tag-group").Select(g => g.GetAttribute("data-resource-group-key"))));
        Assert.Equal(["api", "api", "cache"], cut.FindAll(".resource-tag-group .resource-row-name").Select(n => n.TextContent.Trim()));
        Assert.Empty(Services.GetRequiredService<ResourceTagStore>().GetAllTags());
    }
 
    [Theory]
    [InlineData("Tags")]
    [InlineData("ResourceType")]
    public void Grouping_ParametersJoinTagGroupsAndKeepTheirSectionOtherwise(string grouping)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var name = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag;
        Dictionary<string, ResourcePropertyViewModel> Tags(params string[] tags) => new()
        {
            [name] = new(name, Value.ForList([.. tags.Select(Value.ForString)]), false, null, 0, null, false)
        };
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", resourceType: "Project", properties: Tags("Backend")),
                ModelTestHelpers.CreateResource("api-key", resourceType: KnownResourceTypes.Parameter, properties: Tags("Backend")),
                ModelTestHelpers.CreateResource("theme", resourceType: KnownResourceTypes.Parameter)
            ], resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneGrouping ? (true, grouping) : (false, null);
        Render<FluentToastProvider>();
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".resource-tag-group")));
        var groups = cut.FindAll(".resource-tag-group").Select(g => (
            g.GetAttribute("data-resource-group-key"),
            string.Join(", ", g.QuerySelectorAll(".resource-row-name").Select(n => n.TextContent.Trim())))).ToList();
        var sections = cut.FindAll(".resource-pane-section-header > span:first-child").Select(s => s.TextContent.Trim()).ToList();
        if (grouping == "Tags")
        {
            // A parameter shows with the resources that share its tags, after them. Untagged ones are with the untagged.
            Assert.Equal([("Tags:Backend", "api, api-key"), ("Tags:", "theme")], groups);
            Assert.Empty(sections);
        }
        else
        {
            // Other groupings are about resources: the parameters stay in their own section rather than disappearing.
            Assert.Equal([("ResourceType:Project", "api")], groups);
            Assert.Equal(["Parameters"], sections);
            Assert.Equal(
                ["api-key", "theme"],
                cut.FindAll(".resource-pane-section-header ~ .resource-row .resource-row-name").Select(n => n.TextContent.Trim()));
        }
    }
 
    [Theory]
    [InlineData("Tags", "Tags:A new group")]
    [InlineData("ResourceType", "ResourceType:AAA")]
    [InlineData("State", "State:Building")]
    public async Task AppHost_GroupMovesPreserveMembershipAndModeSpecificOrderAndAppendNewGroups(string grouping, string newGroupKey)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, resourceType: "Project"),
                ModelTestHelpers.CreateResource("cache", state: KnownResourceState.Exited),
                ModelTestHelpers.CreateResource("worker", resourceType: "Executable")
            ], resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneGrouping ? (true, grouping) : (false, null);
        var tags = Services.GetRequiredService<ResourceTagStore>();
        tags.AddTag("api", "Alpha");
        tags.AddTag("cache", "Beta");
        tags.AddTag("api", "Untagged");
        Render<FluentToastProvider>();
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        var store = Services.GetRequiredService<ResourceOrderStore>();
        var resourcePositions = store.GetPositions();
        var otherModes = new[] { "Tags", "ResourceType", "State" }.Where(mode => mode != grouping)
            .ToDictionary(mode => mode, store.GetGroupPositions);
        var originalKeys = GetKeys();
        var memberships = cut.FindAll(".resource-tag-group").ToDictionary(group => group.GetAttribute("data-resource-group-key")!,
            group => group.QuerySelectorAll(".resource-row-name").Select(name => name.TextContent.Trim()).ToArray());
        var movedKey = originalKeys[^1];
        var targetKey = originalKeys[0];
        Assert.Equal(originalKeys.Length, cut.FindAll("[data-resource-group-drag-handle]").Count);
        Assert.Equal("true", cut.Find(".resource-pane-list").GetAttribute("data-group-reorder-enabled"));
        Assert.True(await cut.InvokeAsync(() => cut.Instance.ReorderGroupAsync(grouping, movedKey, targetKey, after: false)));
        var expectedKeys = new[] { movedKey }.Concat(originalKeys[..^1]).ToArray();
        Assert.Equal(expectedKeys, GetKeys());
        Assert.All(memberships, pair => Assert.Equal(pair.Value,
            GetGroup(pair.Key).QuerySelectorAll(".resource-row-name").Select(name => name.TextContent.Trim())));
        Assert.Equal(resourcePositions, store.GetPositions());
        Assert.All(otherModes, pair => Assert.Equal(pair.Value, store.GetGroupPositions(pair.Key)));
        Assert.Equal(["Alpha", "Untagged"], tags.GetTags("api"));
        Assert.Equal(["Beta"], tags.GetTags("cache"));
 
        GetGroup(movedKey).QuerySelector(".resource-row-toggle")!.Click();
        Assert.True(await cut.InvokeAsync(() => cut.Instance.ReorderGroupAsync(grouping, movedKey, targetKey, after: true)));
        Assert.Empty(GetGroup(movedKey).QuerySelectorAll(".resource-row"));
        Assert.Equal("false", GetGroup(movedKey).QuerySelector(".resource-row-toggle")!.GetAttribute("aria-expanded"));
        Assert.True(await cut.InvokeAsync(() => cut.Instance.ReorderGroupAsync(grouping, movedKey, targetKey, after: false)));
        GetGroup(movedKey).QuerySelector(".resource-row-toggle")!.Click();
 
        var search = cut.FindComponent<FluentTextInput>();
        await cut.InvokeAsync(() => search.Instance.ValueChanged.InvokeAsync("api"));
        if (grouping == "Tags")
        {
            tags.AddTag("api", "A new group");
        }
        else
        {
            await channel.Writer.WriteAsync([new(ResourceViewModelChangeType.Upsert,
                ModelTestHelpers.CreateResource("new-resource", state: KnownResourceState.Building, resourceType: "AAA"))]);
        }
        cut.WaitForAssertion(() => Assert.True(store.GetGroupPositions(grouping).ContainsKey(newGroupKey[(grouping.Length + 1)..])));
        await cut.InvokeAsync(() => search.Instance.ValueChanged.InvokeAsync(string.Empty));
        cut.WaitForAssertion(() => Assert.Equal(expectedKeys.Append(newGroupKey), GetKeys()));
 
        cut.Find("#resourcePaneGroupingButton").Click();
        var menu = cut.FindComponents<AspireMenuButton>().Single(button => button.Instance.MenuButtonId == "resourcePaneGroupingButton");
        var otherMode = otherModes.Keys.First();
        await cut.InvokeAsync(menu.Instance.Items.Single(item => item.Id == $"resource-grouping-{otherMode}").OnClick!);
        Assert.False(await cut.InvokeAsync(() => cut.Instance.ReorderGroupAsync(grouping, movedKey, targetKey, after: false)));
        cut.Find("#resourcePaneGroupingButton").Click();
        await cut.InvokeAsync(menu.Instance.Items.Single(item => item.Id == $"resource-grouping-{grouping}").OnClick!);
        Assert.Equal(expectedKeys.Append(newGroupKey), GetKeys());
 
        string[] GetKeys() => cut.FindAll(".resource-tag-group").Select(group => group.GetAttribute("data-resource-group-key")!).ToArray();
        AngleSharp.Dom.IElement GetGroup(string key) => cut.FindAll(".resource-tag-group").Single(group => group.GetAttribute("data-resource-group-key") == key);
    }
 
    [Theory]
    [InlineData("Search")]
    [InlineData("types")]
    [InlineData("states")]
    [InlineData("health")]
    public async Task AppHost_GroupReorderingRequiresClearSearchAndFilters(string filter)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, resourceType: "Project"),
                ModelTestHelpers.CreateResource("cache", state: KnownResourceState.Exited)
            ], resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneGrouping ? (true, "State") : (false, null);
        Render<FluentToastProvider>();
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("[data-resource-group-drag-handle]").Count));
        var positions = Services.GetRequiredService<ResourceOrderStore>().GetGroupPositions("State");
        Func<Task> reset;
        if (filter == "Search")
        {
            var search = cut.FindComponent<FluentTextInput>();
            await cut.InvokeAsync(() => search.Instance.ValueChanged.InvokeAsync("a"));
            reset = () => search.Instance.ValueChanged.InvokeAsync(string.Empty);
        }
        else
        {
            var filterPopover = OpenFilter(cut);
            await SetAllFilterValuesAsync(cut, filterPopover, filter, isVisible: false);
            reset = () => SetAllFilterValuesAsync(cut, filterPopover, filter, isVisible: true);
        }
 
        Assert.Equal("false", cut.Find(".resource-pane-list").GetAttribute("data-group-reorder-enabled"));
        Assert.Empty(cut.FindAll("[data-resource-group-drag-handle]"));
        Assert.False(await cut.InvokeAsync(() => cut.Instance.ReorderGroupAsync("State", "State:Running", "State:Exited", after: false)));
        Assert.Equal(positions, Services.GetRequiredService<ResourceOrderStore>().GetGroupPositions("State"));
        await cut.InvokeAsync(reset);
        Assert.Equal(2, cut.FindAll("[data-resource-group-drag-handle]").Count);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void AppHost_ResourceLinksHaveAccessibleMetadataInsteadOfHoverTooltips(bool collapsed)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("cache", state: KnownResourceState.Running, properties: new()
            {
                [KnownProperties.Container.Lifetime] = new(KnownProperties.Container.Lifetime, Value.ForString("Persistent"), false, null, 0, null, false)
            })], resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneCollapsed ? (true, collapsed) : (false, null);
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        Assert.Equal(
            [
                ("cache", "Running; Container (persistent)", (string?)null)
            ],
            cut.FindAll(".resource-row-link").Select(link => (
                link.GetAttribute("aria-label"), link.GetAttribute("aria-description"), link.GetAttribute("data-tooltip"))));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task Standalone_SourceLinksHaveAccessibleNamesInsteadOfHoverTooltips(bool collapsed)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: false));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneCollapsed ? (true, collapsed) : (false, null);
        await AddTelemetryAsync("api");
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        Assert.Equal(
            [
                ("All sources", "1 telemetry source", (string?)null),
                ("api", Resources.Layout.ResourcePaneTelemetryOnly, null)
            ],
            cut.FindAll(".resource-row-link").Select(link => (
                link.GetAttribute("aria-label"), link.GetAttribute("aria-description"), link.GetAttribute("data-tooltip"))));
    }
 
    [Fact]
    public async Task AppHost_TelemetryOnlyResourcesAreHiddenUntilEnabled()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("cache")],
            resourceChannelProvider: () => channel));
        await AddTelemetryAsync("browser");
 
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        Assert.Equal(["cache"], cut.FindAll(".resource-row-link").Select(link => link.GetAttribute("aria-label")));
 
        var telemetryToggle = cut.Find(".resource-kind-toggle[aria-label='Telemetry sources']");
        Assert.Equal("false", telemetryToggle.GetAttribute("aria-pressed"));
        Assert.Equal("1 telemetry source", telemetryToggle.GetAttribute("aria-description"));
 
        telemetryToggle.Click();
 
        cut.WaitForAssertion(() => Assert.Equal(
            ["cache", "browser"],
            cut.FindAll(".resource-row-link").Select(link => link.GetAttribute("aria-label"))));
        Assert.Equal("1 resource, 1 telemetry source", cut.Find(".resource-pane-foot > span").GetAttribute("aria-label"));
    }
 
    [Fact]
    public void AppHost_CountsResourcesAndParametersSeparately()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api"),
                ModelTestHelpers.CreateResource("worker"),
                ModelTestHelpers.CreateResource("parameter", resourceType: KnownResourceTypes.Parameter)
            ],
            resourceChannelProvider: () => channel));
 
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(
                [("Resources", "2 resources"), ("Parameters", "1 parameter"), ("Telemetry sources", "0 telemetry sources")],
                cut.FindAll(".resource-kind-toggle").Select(toggle => (toggle.GetAttribute("aria-label")!, toggle.GetAttribute("aria-description")!)));
            var countSummary = cut.Find(".resource-pane-foot > span");
            Assert.Equal("img", countSummary.GetAttribute("role"));
            Assert.Equal("2 resources, 1 parameter", countSummary.GetAttribute("aria-label"));
        });
    }
 
    [Fact]
    public async Task AppHost_ReorderingPreservesHierarchyAndAppendsNewSiblings()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("root"),
                ModelTestHelpers.CreateResource("worker"),
                ModelTestHelpers.CreateResource("a", properties: new()
                {
                    [KnownProperties.Resource.ParentName] = new(KnownProperties.Resource.ParentName, Value.ForString("root"), false, null, 0, null, false)
                }),
                ModelTestHelpers.CreateResource("b", properties: new()
                {
                    [KnownProperties.Resource.ParentName] = new(KnownProperties.Resource.ParentName, Value.ForString("root"), false, null, 0, null, false)
                })
            ], resourceChannelProvider: () => channel));
        var store = Services.GetRequiredService<ResourceOrderStore>();
        store.EnsureResources(["worker_0", "root_0", "b_0", "a_0"]);
        var toasts = Render<FluentToastProvider>();
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(["worker", "root", "b", "a"], GetNames()));
 
        Assert.True(await cut.InvokeAsync(() => cut.Instance.ReorderResourceAsync("root", "worker", after: false)));
        Assert.Equal(["root", "b", "a", "worker"], GetNames());
        Assert.True(await cut.InvokeAsync(() => cut.Instance.ReorderResourceAsync("a", "b", after: false)));
        Assert.Equal(["root", "a", "b", "worker"], GetNames());
        Assert.False(await cut.InvokeAsync(() => cut.Instance.ReorderResourceAsync("a", "worker", after: true)));
        Assert.Equal(Resources.Layout.ResourcePaneReorderUnavailable, Assert.Single(toasts.FindComponents<FluentToast>()).Instance.Title);
        Assert.Equal(["root", "a", "b", "worker"], GetNames());
 
        cut.Find("[data-resource-name='root'] .resource-row-toggle").Click();
        Assert.Equal(["root", "worker"], GetNames());
        Assert.True(await cut.InvokeAsync(() => cut.Instance.ReorderResourceAsync("worker", "root", after: false)));
        cut.Find("[data-resource-name='root'] .resource-row-toggle").Click();
        Assert.Equal(["worker", "root", "a", "b"], GetNames());
 
        await channel.Writer.WriteAsync([new(ResourceViewModelChangeType.Upsert, ModelTestHelpers.CreateResource("0", properties: new()
        {
            [KnownProperties.Resource.ParentName] = new(KnownProperties.Resource.ParentName, Value.ForString("root"), false, null, 0, null, false)
        }))]);
        cut.WaitForAssertion(() => Assert.Equal(["worker", "root", "a", "b", "0"], GetNames()));
 
        Assert.Equal(["worker_0", "root_0", "a_0", "b_0", "0_0"],
            store.GetPositions().OrderBy(pair => pair.Value).Select(pair => pair.Key));
 
        string?[] GetNames() => cut.FindAll("[data-resource-name]").Select(row => row.GetAttribute("data-resource-name")).ToArray();
    }
 
    [Fact]
    public async Task CompactDensity_RendersOneLineRowsWithAlignedColumns()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, resourceType: "Project"),
                ModelTestHelpers.CreateResource("cache", state: KnownResourceState.Running, resourceType: "Container", properties: new()
                {
                    [KnownProperties.Container.Lifetime] = new(KnownProperties.Container.Lifetime, Value.ForString("Persistent"), false, null, 0, null, false)
                })
            ], resourceChannelProvider: () => channel));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("[data-resource-name] .resource-row-sub").Count));
        Assert.Empty(cut.FindAll(".resource-row-meta"));
 
        // The setting applies right away, without reloading the page.
        await cut.InvokeAsync(() => Services.GetRequiredService<DashboardViewPreferences>().SetDensityAsync(ResourceListDensity.Compact));
 
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".resource-pane-list.density-compact")));
        Assert.Empty(cut.FindAll("[data-resource-name] .resource-row-sub"));
        Assert.Empty(cut.FindAll("[data-resource-name='api'] .persistent-container-icon"));
        var api = cut.Find("[data-resource-name='api'] .resource-row-meta");
        Assert.Equal("Project", api.QuerySelector(".resource-row-type")!.TextContent);
        Assert.Equal("Running", api.QuerySelector(".resource-row-meta-state")!.GetAttribute("data-tooltip"));
        Assert.Empty(cut.FindAll("[data-resource-name='cache'] .persistent-container-icon"));
        var cache = cut.Find("[data-resource-name='cache'] .resource-row-meta");
        Assert.Empty(cache.QuerySelectorAll(".persistent-container-icon"));
        Assert.Equal("Container (persistent)", cache.QuerySelector(".resource-row-type")!.TextContent);
    }
 
    [Fact]
    public async Task TerminalTab_NavigatesWithinTheSelectionAndFallsBackForNonTerminalResources()
    {
        var viewport = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                TerminalSetupHelpers.CreateTerminalResource("shell"),
                TerminalSetupHelpers.CreateTerminalResource("other-shell"),
                ModelTestHelpers.CreateResource("api")
            ], resourceChannelProvider: () => channel));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.ResourceOverviewUrl("shell"));
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        Assert.True(cut.Instance.IsTabAvailable(ResourceTab.Terminal));
        var terminalTab = cut.FindComponents<FluentTab>().Single(t => t.Instance.Id == "tab-Terminal");
        Assert.True(terminalTab.Instance.Visible);
        await cut.InvokeAsync(() => cut.FindComponent<FluentTabs>().Instance.ActiveTabChanged.InvokeAsync(terminalTab.Instance));
        Assert.Equal("http://localhost/resources/shell?terminal=true", navigation.Uri);
        cut.Render();
        Assert.Equal("tab-Terminal", cut.FindComponent<FluentTabs>().Instance.ActiveTabId);
        var previousTabs = cut.FindComponent<FluentTabs>().Instance;
        var previousTabChanged = cut.FindComponent<FluentTabs>().Instance.ActiveTabChanged;
        var overviewTab = cut.FindComponents<FluentTab>().Single(t => t.Instance.Id == "tab-Overview").Instance;
 
        cut.Find(".resource-row-link[aria-label='other-shell']").Click();
        Assert.Equal("http://localhost/resources/other-shell?terminal=true", navigation.Uri);
        cut.Render();
        Assert.NotSame(previousTabs, cut.FindComponent<FluentTabs>().Instance);
        Assert.Equal(["other-shell"], cut.Instance.SelectedResourceNames);
        await cut.InvokeAsync(() => previousTabChanged.InvokeAsync(overviewTab));
        Assert.Equal("http://localhost/resources/other-shell?terminal=true", navigation.Uri);
 
        cut.Find(".resource-row-link[aria-label='api']").Click();
        Assert.Equal("http://localhost/resources/api", navigation.Uri);
        cut.Render();
        Assert.False(cut.Instance.IsTabAvailable(ResourceTab.Terminal));
        Assert.False(cut.FindComponents<FluentTab>().Single(t => t.Instance.Id == "tab-Terminal").Instance.Visible);
    }
 
    [Fact]
    public async Task TerminalTab_FocusAndOverviewPreservePaneQueryAndFragment()
    {
        var viewport = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [TerminalSetupHelpers.CreateTerminalResource("shell")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo("/resources/shell?pane=tags#output");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        var terminal = cut.FindComponents<FluentTab>().Single(t => t.Instance.Id == "tab-Terminal").Instance;
        await cut.InvokeAsync(() => cut.FindComponent<FluentTabs>().Instance.ActiveTabChanged.InvokeAsync(terminal));
        Assert.Equal("http://localhost/resources/shell?pane=tags&terminal=true#output", navigation.Uri);
        cut.Render();
        var overview = cut.FindComponents<FluentTab>().Single(t => t.Instance.Id == "tab-Overview").Instance;
        await cut.InvokeAsync(() => cut.FindComponent<FluentTabs>().Instance.ActiveTabChanged.InvokeAsync(overview));
        Assert.Equal("http://localhost/resources/shell?pane=tags#output", navigation.Uri);
    }
 
    [Fact]
    public void Tabs_ShowTheSelectionsPagesAndNeverDisableThoseWithoutTelemetry()
    {
        var viewport = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("worker")],
            resourceChannelProvider: () => channel));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.AddResourceSelection(DashboardUrls.ConsoleLogsUrl(), ["api", "worker"]));
 
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() => Assert.Equal(
            ["tab-Overview", "tab-Console", "tab-StructuredLogs", "tab-Traces", "tab-Metrics"],
            cut.FindComponents<FluentTab>().Where(t => t.Instance.Visible).Select(t => t.Instance.Id)));
        Assert.Equal("tab-Console", cut.FindComponent<FluentTabs>().Instance.ActiveTabId);
        // The resources send no telemetry, so their telemetry tabs are flagged unavailable but stay enabled.
        Assert.DoesNotContain(cut.FindComponents<FluentTab>(), t => t.Instance.Disabled);
        Assert.Equal(
            ["tab-StructuredLogs", "tab-Traces", "tab-Metrics"],
            cut.FindComponents<FluentTab>().Where(t => t.Markup.Contains(Resources.Layout.ResourceTabUnavailable)).Select(t => t.Instance.Id));
 
        // Selecting an unavailable tab still navigates.
        cut.InvokeAsync(() => cut.FindComponent<FluentTabs>().Instance.ActiveTabChanged.InvokeAsync(
            cut.FindComponents<FluentTab>().Single(t => t.Instance.Id == "tab-Traces").Instance));
        Assert.Equal("http://localhost/traces?resource=api&resource=worker", navigation.Uri);
        cut.Render();
 
        cut.InvokeAsync(() => cut.FindComponent<FluentTabs>().Instance.ActiveTabChanged.InvokeAsync(
            cut.FindComponents<FluentTab>().Single(t => t.Instance.Id == "tab-Overview").Instance));
        Assert.Equal("http://localhost/resources?resource=api&resource=worker", navigation.Uri);
    }
 
    [Fact]
    public async Task Tabs_ClickedTabStaysActiveUntilItsPageShows()
    {
        // The tab strip is inline on narrow viewports; on desktop it's in the app's header, which isn't rendered here.
        var viewport = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")],
            resourceChannelProvider: () => channel));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.ResourceOverviewUrl("api"));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal("tab-Overview", cut.FindComponent<FluentTabs>().Instance.ActiveTabId));
 
        Task SelectTabAsync(string id) => cut.InvokeAsync(() => cut.FindComponent<FluentTabs>().Instance.ActiveTabChanged.InvokeAsync(
            cut.FindComponents<FluentTab>().Single(t => t.Instance.Id == id).Instance));
 
        await SelectTabAsync("tab-Console");
 
        // Until the router gives the layout the new page, the strip keeps the clicked tab. Rendering the previous tab
        // would make the strip report it as a change once the new page shows, which navigated back, endlessly.
        Assert.Equal("http://localhost/consolelogs/resource/api", navigation.Uri);
        Assert.Equal("tab-Console", cut.FindComponent<FluentTabs>().Instance.ActiveTabId);
        await SelectTabAsync("tab-Overview");
        Assert.Equal("http://localhost/consolelogs/resource/api", navigation.Uri);
 
        // Once the new page shows (the router sets the layout's parameters again), tab clicks navigate again.
        cut.Render();
        await SelectTabAsync("tab-Overview");
        Assert.Equal("http://localhost/resources/api", navigation.Uri);
    }
 
    [Fact]
    public async Task RowActions_RunHighlightedCommandsAndOfferTheResourceMenu()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var executed = new List<(string Resource, string Command)>();
        var stop = new CommandViewModel("resource-stop", CommandViewModelState.Enabled, "Stop", "Stop resource", confirmationMessage: "", argumentInputs: [], isHighlighted: true, iconName: "Stop", iconVariant: IconVariant.Filled);
        var other = new CommandViewModel("custom", CommandViewModelState.Enabled, "Custom command", "", confirmationMessage: "", argumentInputs: [], isHighlighted: false, iconName: string.Empty, iconVariant: IconVariant.Regular);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, commands: [stop, other])],
            resourceChannelProvider: () => channel,
            executeResourceCommand: (resource, _, command, _, _) =>
            {
                executed.Add((resource, command.Name));
                return Task.FromResult(new ResourceCommandResponseViewModel { Kind = ResourceCommandResponseKind.Succeeded });
            }));
        Services.GetRequiredService<NavigationManager>().NavigateTo(DashboardUrls.ConsoleLogsUrl("api"));
        Render<FluentToastProvider>();
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll("[data-resource-name='api'] .resource-row-actions")));
 
        // The actions are on the rows and in the overview, not above the resource's other pages.
        Assert.Empty(cut.FindAll(".resource-header .resource-actions"));
        var stopButton = Assert.Single(cut.FindAll("[data-resource-name='api'] .resource-row-actions fluent-button[aria-label='Stop api']"));
        await cut.InvokeAsync(() => stopButton.Click());
        cut.WaitForAssertion(() => Assert.Equal([("api", "resource-stop")], executed));
 
        var menuButton = cut.FindComponents<AspireMenuButton>().Single(b => b.Instance.Title == "Actions for api");
        Assert.Contains(menuButton.Instance.ItemsProvider(), item => item.Text == "Custom command");
    }
 
    [Theory]
    [InlineData(true, ResourceListDensity.Comfortable)]
    [InlineData(true, ResourceListDensity.Compact)]
    [InlineData(true, ResourceListDensity.Spacious)]
    [InlineData(false, ResourceListDensity.Comfortable)]
    public async Task RowTerminal_SelectsOnlyItsResourceAndPreservesViewState(bool desktop, ResourceListDensity density)
    {
        var viewport = new ViewportInformation(IsDesktop: desktop, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var stop = new CommandViewModel("resource-stop", CommandViewModelState.Enabled, "Stop", "Stop resource", confirmationMessage: "", argumentInputs: [], isHighlighted: true, iconName: "Stop", iconVariant: IconVariant.Filled);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("shell-instance", displayName: "shell", state: KnownResourceState.Running,
                    urls: [new UrlViewModel("https", new Uri("https://localhost:5001"), false, false, UrlDisplayPropertiesViewModel.Empty)],
                    commands: [stop],
                    properties: new()
                    {
                        [KnownProperties.Terminal.Enabled] = new(KnownProperties.Terminal.Enabled, Value.ForString("true"), false, null, 0, null, false)
                    }),
                ModelTestHelpers.CreateResource("api")
            ],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        const string initialUrl = "/resources?resource=api&resource=shell&pane=tags&filter=one&filter=two#output";
        const string terminalUrl = "/resources/shell?pane=tags&filter=one&filter=two&terminal=true#output";
        navigation.NavigateTo(initialUrl);
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        await cut.InvokeAsync(() => Services.GetRequiredService<DashboardViewPreferences>().SetDensityAsync(density));
 
        var terminal = Assert.Single(cut.FindAll(".resource-row-terminal"));
        Assert.Equal(terminalUrl, terminal.GetAttribute("href"));
        Assert.Equal("Open terminal for shell", terminal.GetAttribute("aria-label"));
        Assert.Equal("Open terminal for shell", terminal.GetAttribute("data-tooltip"));
        Assert.Collection(terminal.ParentElement!.Children,
            endpoint => Assert.True(endpoint.ClassList.Contains("resource-row-action-priority-0")),
            shortcut => Assert.True(shortcut.ClassList.Contains("resource-row-action-priority-1")),
            command => Assert.True(command.ClassList.Contains("resource-row-action-priority-2")),
            menu => Assert.True(menu.ClassList.Contains("aspire-menu-button")));
 
        if (!desktop)
        {
            cut.Find(".resource-pane-open-button").Click();
            Assert.True(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
        }
 
        // Modifier clicks on the shortcut must not invoke the row's multi-selection behavior.
        await cut.InvokeAsync(() => cut.Find(".resource-row-terminal").Click(new Microsoft.AspNetCore.Components.Web.MouseEventArgs { CtrlKey = true }));
        Assert.Equal("http://localhost" + terminalUrl, navigation.Uri);
        cut.Render();
        Assert.Equal(["shell"], cut.Instance.SelectedResourceNames);
        Assert.Equal(ResourceTab.Terminal, cut.Instance.CurrentTab);
        Assert.False(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
 
        // The narrow-pane overflow menu must have the same navigation semantics as the shortcut.
        navigation.NavigateTo(initialUrl);
        cut.Render();
        var menu = cut.FindComponents<AspireMenuButton>().Single(button => button.Instance.Title == "Actions for shell");
        var terminalItem = Assert.Single(menu.Instance.ItemsProvider(), item => item.Text == "Terminal");
        await cut.InvokeAsync(Assert.IsType<Func<Task>>(terminalItem.OnClick));
        Assert.Equal("http://localhost" + terminalUrl, navigation.Uri);
        cut.Render();
        Assert.Equal(["shell"], cut.Instance.SelectedResourceNames);
        Assert.Equal(ResourceTab.Terminal, cut.Instance.CurrentTab);
    }
 
    [Fact]
    public void RowTerminal_IsUnavailableInReadOnlyDashboards()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true, isReadOnly: true,
            initialResources: [TerminalSetupHelpers.CreateTerminalResource("shell")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        Assert.Empty(cut.FindAll(".resource-row-terminal"));
        var menu = cut.FindComponents<AspireMenuButton>().Single(button => button.Instance.Title == "Actions for shell");
        Assert.All(menu.Instance.ItemsProvider(), item => Assert.NotEqual("Terminal", item.Text));
        Assert.False(cut.Instance.IsTabAvailable(ResourceTab.Terminal));
    }
 
    // The filters are a popover that stays open while values change. Expands only the given group, so the checkboxes
    // found are that group's: the first is "(All)", the rest are its values in order.
    private static IRenderedComponent<ResourcePaneFilter> OpenFilter(IRenderedComponent<ResourcesLayout> cut)
    {
        cut.Find("#resourcePaneFilterButton").Click();
        return cut.FindComponent<ResourcePaneFilter>();
    }
 
    private static List<IRenderedComponent<FluentCheckbox>> GetFilterCheckboxes(IRenderedComponent<ResourcePaneFilter> filter, string groupId)
    {
        var target = groupId switch { "types" => 0, "states" => 1, "health" => 2, _ => 3 };
        var count = filter.FindAll(".resource-filter-group-header").Count;
        for (var i = 0; i < count; i++)
        {
            var header = filter.FindAll(".resource-filter-group-header")[i];
            if ((header.GetAttribute("aria-expanded") == "true") != (i == target))
            {
                header.Click();
            }
        }
 
        return filter.FindComponents<FluentCheckbox>().ToList();
    }
 
    private static Task SetAllFilterValuesAsync(IRenderedComponent<ResourcesLayout> cut, IRenderedComponent<ResourcePaneFilter> filter, string groupId, bool isVisible) =>
        cut.InvokeAsync(() => GetFilterCheckboxes(filter, groupId)[0].Instance.CheckStateChanged.InvokeAsync(isVisible));
 
    [Fact]
    public void FilterPopover_ShowsHowManyResourcesEachValueMatches()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running),
                ModelTestHelpers.CreateResource("worker", state: KnownResourceState.Running),
                ModelTestHelpers.CreateResource("cache", state: KnownResourceState.FailedToStart)
            ],
            resourceChannelProvider: () => channel));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("[data-resource-name]").Count));
 
        var filter = OpenFilter(cut);
        var boxes = GetFilterCheckboxes(filter, "states");
 
        // "All" has no count: it isn't a value of the filter.
        Assert.Equal("(All)", boxes[0].Instance.Label);
        Assert.Equal(
            [(nameof(KnownResourceState.FailedToStart), "1"), (nameof(KnownResourceState.Running), "2")],
            boxes.Skip(1).Select(box => box.Instance.Label!).Zip(filter.FindAll(".resource-filter-option .resource-filter-count").Select(count => count.TextContent.Trim())));
    }
 
    [Fact]
    public async Task FilterPopover_FiltersResourcesByTags()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api"),
                ModelTestHelpers.CreateResource("worker"),
                ModelTestHelpers.CreateResource("cache")
            ],
            resourceChannelProvider: () => channel));
        var tags = Services.GetRequiredService<ResourceTagStore>();
        tags.AddTag("api", "Backend");
        tags.AddTag("worker", "Backend");
        tags.AddTag("worker", "Shared");
 
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("[data-resource-name]").Count));
 
        var filter = OpenFilter(cut);
        var boxes = GetFilterCheckboxes(filter, "tags");
 
        Assert.Equal(
            [("(All)", (string?)null), ("Untagged", "1"), ("Backend", "2"), ("Shared", "1")],
            boxes.Select((box, index) => (box.Instance.Label!, index == 0 ? null : filter.FindAll(".resource-filter-option .resource-filter-count")[index - 1].TextContent.Trim())));
 
        await SetAllFilterValuesAsync(cut, filter, "tags", isVisible: false);
        await cut.InvokeAsync(() => GetFilterCheckboxes(filter, "tags").Single(box => box.Instance.Label == "Backend").Instance.ValueChanged.InvokeAsync(true));
 
        cut.WaitForAssertion(() => Assert.Equal(
            ["api", "worker"],
            cut.FindAll("[data-resource-name]").Select(e => e.GetAttribute("data-resource-name"))));
 
        // The popover stays open while values change, and OK only closes it.
        Assert.Single(cut.FindAll(".resource-filter"));
        cut.Find(".resource-filter-done").Click();
        Assert.Empty(cut.FindAll(".resource-filter"));
        Assert.Equal(["api", "worker"], cut.FindAll("[data-resource-name]").Select(e => e.GetAttribute("data-resource-name")));
    }
 
    [Fact]
    public async Task FilterPopover_TagGroupingShowsOnlyTheFilteredTags()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api"),
                ModelTestHelpers.CreateResource("worker"),
                ModelTestHelpers.CreateResource("cache")
            ],
            resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneGrouping ? (true, "Tags") : (false, null);
        var tags = Services.GetRequiredService<ResourceTagStore>();
        tags.AddTag("api", "Backend");
        tags.AddTag("api", "API");
        tags.AddTag("worker", "Backend");
 
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(["Tags:API", "Tags:Backend", "Tags:"],
            cut.FindAll(".resource-tag-group").Select(g => g.GetAttribute("data-resource-group-key"))));
 
        var filter = OpenFilter(cut);
        await SetAllFilterValuesAsync(cut, filter, "tags", isVisible: false);
        await cut.InvokeAsync(() => GetFilterCheckboxes(filter, "tags").Single(box => box.Instance.Label == "Backend").Instance.ValueChanged.InvokeAsync(true));
 
        // api also carries API, but only the filtered tag keeps a group.
        cut.WaitForAssertion(() => Assert.Equal(["Tags:Backend"],
            cut.FindAll(".resource-tag-group").Select(g => g.GetAttribute("data-resource-group-key"))));
        Assert.Equal(["api", "worker"], cut.FindAll(".resource-tag-group .resource-row-name").Select(n => n.TextContent.Trim()));
    }
 
    [Fact]
    public async Task FilterPopover_ResetShowsEveryValueAgain()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running),
                ModelTestHelpers.CreateResource("cache", state: KnownResourceState.Exited)
            ],
            resourceChannelProvider: () => channel));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("[data-resource-name]").Count));
 
        var filter = OpenFilter(cut);
        Assert.True(cut.Find(".resource-filter-reset").HasAttribute("disabled"));
 
        await SetAllFilterValuesAsync(cut, filter, "states", isVisible: false);
        cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-resource-name]")));
        Assert.False(cut.Find(".resource-filter-reset").HasAttribute("disabled"));
 
        cut.Find(".resource-filter-reset").Click();
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("[data-resource-name]").Count));
        Assert.True(cut.Find(".resource-filter-reset").HasAttribute("disabled"));
    }
 
    [Fact]
    public async Task KindToggles_HideAndShowEachKindOfItem()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api"),
                ModelTestHelpers.CreateResource("parameter", resourceType: KnownResourceTypes.Parameter)
            ],
            resourceChannelProvider: () => channel));
        await AddTelemetryAsync("api");
        await AddTelemetryAsync("browser");
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("[data-resource-name]").Count));
 
        // Resources and parameters are on, telemetry sources are off. Each toggle names its kind and count.
        Assert.Equal(
            [("Resources", "1 resource", "true"), ("Parameters", "1 parameter", "true"), ("Telemetry sources", "1 telemetry source", "false")],
            cut.FindAll(".resource-kind-toggle").Select(toggle => (toggle.GetAttribute("aria-label")!, toggle.GetAttribute("aria-description")!, toggle.GetAttribute("aria-pressed")!)));
        Assert.Equal("All resources", cut.Find(".resource-header-title").TextContent.Trim());
 
        cut.Find(".resource-kind-toggle[aria-label='Parameters']").Click();
        cut.WaitForAssertion(() => Assert.Equal(["api"], cut.FindAll("[data-resource-name]").Select(e => e.GetAttribute("data-resource-name"))));
        Assert.Equal("false", cut.Find(".resource-kind-toggle[aria-label='Parameters']").GetAttribute("aria-pressed"));
        Assert.Equal(["api"], cut.Instance.GetScopeResourceNames());
 
        cut.Find(".resource-kind-toggle[aria-label='Telemetry sources']").Click();
        cut.WaitForAssertion(() => Assert.Equal(["api", "browser"], cut.FindAll(".resource-row-name").Select(e => e.TextContent.Trim())));
        Assert.Equal(["Resources, Telemetry sources"], cut.FindAll(".resource-header-title").Select(e => e.TextContent.Trim()));
        Assert.Equal(["api", "browser"], cut.Instance.GetSelectionTelemetryKeys(null)!.Select(key => key.Name));
 
        cut.Find(".resource-kind-toggle[aria-label='Resources']").Click();
        cut.WaitForAssertion(() => Assert.Equal(["browser"], cut.FindAll(".resource-row-name").Select(e => e.TextContent.Trim())));
        Assert.Equal(["browser"], cut.Instance.GetSelectionTelemetryKeys(null)!.Select(key => key.Name));
 
        cut.Find(".resource-kind-toggle[aria-label='Telemetry sources']").Click();
        cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".resource-row-name")));
        Assert.Equal("Nothing shown", cut.Find(".resource-header-title").TextContent.Trim());
        Assert.Contains(Resources.Layout.ResourcePaneNoKindsHint, cut.Find(".resource-pane-message").TextContent);
    }
 
    [Fact]
    public void Landing_WithoutRememberedSelection_KeepsAllResourcesSelected()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache")],
            resourceChannelProvider: () => channel));
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ResourceListUrl());
 
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("[data-resource-name]").Count));
        Assert.Equal("resources", navigationManager.ToBaseRelativePath(navigationManager.Uri));
        // Nothing is selected, which means all resources: "Select all" is pressed.
        Assert.Equal("true", cut.Find(".resource-select-all-toggle").GetAttribute("aria-pressed"));
        Assert.Equal("All resources", cut.Find(".resource-header-title").TextContent.Trim());
    }
 
    [Theory]
    [InlineData("/resources/api")]
    [InlineData("/resources/api?terminal=true")]
    [InlineData("/terminals/resource/api")]
    public async Task SelectAll_IsPressedOnlyWhileEverythingIsSelectedAndSelectsEverything(string initialUrl)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [TerminalSetupHelpers.CreateTerminalResource("api"), ModelTestHelpers.CreateResource("cache")],
            resourceChannelProvider: () => channel));
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(initialUrl);
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() => Assert.Equal("api", cut.Find(".resource-header-title").TextContent.Trim()));
        Assert.Equal(["api"], cut.FindAll(".resource-row.selected[data-resource-name]").Select(r => r.GetAttribute("data-resource-name")));
        var selectAll = cut.Find(".resource-select-all-toggle");
        Assert.Equal("false", selectAll.GetAttribute("aria-pressed"));
        Assert.Equal("Select all", selectAll.GetAttribute("aria-label"));
        Assert.Equal("Select all", selectAll.GetAttribute("data-tooltip"));
 
        await cut.InvokeAsync(() => selectAll.Click());
 
        // Like the "All resources" row, it navigates to the resources without a selection. The router then gives the
        // layout the new location.
        Assert.Equal("resources", navigationManager.ToBaseRelativePath(navigationManager.Uri));
        cut.Render();
        cut.WaitForAssertion(() => Assert.Equal("All resources", cut.Find(".resource-header-title").TextContent.Trim()));
        selectAll = cut.Find(".resource-select-all-toggle");
        Assert.Equal("true", selectAll.GetAttribute("aria-pressed"));
        Assert.Equal("All selected", selectAll.GetAttribute("data-tooltip"));
 
        // Every row looks selected, since everything is. Only an explicit selection marks rows as the current ones.
        Assert.Equal(["api", "cache"], cut.FindAll(".resource-row.selected[data-resource-name]").Select(r => r.GetAttribute("data-resource-name")));
        Assert.Empty(cut.FindAll(".resource-row-link[aria-current]"));
    }
 
    [Fact]
    public async Task Metrics_ChangingTheSelectionStaysOnMetricsEvenWithoutTelemetry()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache")],
            resourceChannelProvider: () => channel));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.MetricsUrl("api"));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        // Neither resource sends telemetry, which would otherwise send a selection to the overview.
        await cut.InvokeAsync(() => cut.FindAll(".resource-row-link").Single(link => link.GetAttribute("href") == DashboardUrls.MetricsUrl("cache")).Click());
        Assert.Equal("http://localhost/metrics/resource/cache", navigation.Uri);
        cut.Render();
 
        await cut.InvokeAsync(() => cut.FindAll(".resource-row-link").Single(link => link.GetAttribute("href") == DashboardUrls.MetricsUrl("api"))
            .Click(new Microsoft.AspNetCore.Components.Web.MouseEventArgs { CtrlKey = true }));
        Assert.Equal("http://localhost/metrics/resource/cache?resource=cache&resource=api", navigation.Uri);
        cut.Render();
 
        await cut.InvokeAsync(() => cut.Find(".resource-select-all-toggle").Click());
        Assert.Equal("http://localhost/metrics", navigation.Uri);
    }
 
    [Theory]
    [InlineData(true, "resources")]
    [InlineData(false, "structuredlogs")]
    public void RootUrl_RedirectsToResourcesWithAllResourcesSelected(bool resourceServiceEnabled, string expectedUrl)
    {
        Services.AddSingleton<IDashboardClient>(new TestDashboardClient(isEnabled: resourceServiceEnabled));
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo("/");
 
        Render<Aspire.Dashboard.Components.Pages.Home>();
 
        Assert.Equal(expectedUrl, navigationManager.ToBaseRelativePath(navigationManager.Uri));
    }
 
    [Theory]
    [InlineData("/resources/catalogservice-old", "resources/catalogservice-new")]
    [InlineData("/resources?resource=catalogservice-old&resource=gone-old&resource=cache", "resources?resource=catalogservice-new&resource=cache")]
    // Nothing carries over, so all resources are shown.
    [InlineData("/resources/gone-old", "resources")]
    public void RunSwitch_CarriesSelectionToTheSameResourcesInTheNewRun(string url, string expectedUrl)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("catalogservice-new", displayName: "catalogservice"),
                ModelTestHelpers.CreateResource("cache")
            ], resourceChannelProvider: () => channel));
        // The selection was made in another run, where the replica had a different random suffix.
        var paneState = Services.GetRequiredService<ResourcePaneState>();
        paneState.SelectionRunId = "previous-run";
        paneState.SelectionPersistentKeys = new Dictionary<string, string>
        {
            ["catalogservice-old"] = "catalogservice_0",
            ["gone-old"] = "gone_0",
            ["cache"] = "cache_0"
        };
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(url);
 
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() => Assert.Equal(expectedUrl, navigationManager.ToBaseRelativePath(navigationManager.Uri)));
        Assert.Equal(Services.GetRequiredService<DashboardDataSource>().SelectedRun.RunId, paneState.SelectionRunId);
    }
 
    [Fact]
    public async Task AppHost_NewResourcesAppendAndRunSpecificNamesKeepTheirPosition()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var originalApi = ModelTestHelpers.CreateResource("api-first-run", displayName: "api");
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [originalApi, ModelTestHelpers.CreateResource("cache")], resourceChannelProvider: () => channel));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("[data-resource-drag-handle]").Count));
        Assert.True(await cut.InvokeAsync(() => cut.Instance.ReorderResourceAsync("cache", originalApi.Name, after: false)));
 
        await channel.Writer.WriteAsync([new(ResourceViewModelChangeType.Upsert, ModelTestHelpers.CreateResource("aaa"))]);
        cut.WaitForAssertion(() => Assert.Equal(["cache", "api-first-run", "aaa"],
            cut.FindAll("[data-resource-name]").Select(row => row.GetAttribute("data-resource-name"))));
        await channel.Writer.WriteAsync([
            new(ResourceViewModelChangeType.Delete, originalApi),
            new(ResourceViewModelChangeType.Upsert, ModelTestHelpers.CreateResource("api-second-run", displayName: "api"))
        ]);
        cut.WaitForAssertion(() => Assert.Equal(["cache", "api-second-run", "aaa"],
            cut.FindAll("[data-resource-name]").Select(row => row.GetAttribute("data-resource-name"))));
    }
 
    [Theory]
    [InlineData("Search")]
    [InlineData("types")]
    [InlineData("states")]
    [InlineData("health")]
    [InlineData("Tags")]
    [InlineData("ResourceType")]
    [InlineData("State")]
    public async Task AppHost_ReorderingRequiresNoSearchFiltersOrGrouping(string mode)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, resourceType: "Project"),
                ModelTestHelpers.CreateResource("cache", state: KnownResourceState.Exited)
            ], resourceChannelProvider: () => channel));
        Render<FluentToastProvider>();
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("[data-resource-drag-handle]").Count));
        var positions = Services.GetRequiredService<ResourceOrderStore>().GetPositions();
        Func<Task> reset;
        if (mode == "Search")
        {
            var input = cut.FindComponent<FluentTextInput>();
            await cut.InvokeAsync(() => input.Instance.ValueChanged.InvokeAsync("a"));
            reset = () => input.Instance.ValueChanged.InvokeAsync(string.Empty);
        }
        else if (mode is "types" or "states" or "health")
        {
            var filterPopover = OpenFilter(cut);
            await SetAllFilterValuesAsync(cut, filterPopover, mode, isVisible: false);
            reset = () => SetAllFilterValuesAsync(cut, filterPopover, mode, isVisible: true);
        }
        else
        {
            cut.Find("#resourcePaneGroupingButton").Click();
            var menu = cut.FindComponents<AspireMenuButton>().Single(button => button.Instance.MenuButtonId == "resourcePaneGroupingButton");
            await cut.InvokeAsync(menu.Instance.Items.Single(item => item.Id == $"resource-grouping-{mode}").OnClick!);
            reset = menu.Instance.Items.Single(item => item.Id == "resource-grouping-None").OnClick!;
        }
 
        Assert.Empty(cut.FindAll("[data-resource-drag-handle]"));
        Assert.Equal("false", cut.Find(".resource-pane-list").GetAttribute("data-reorder-enabled"));
        Assert.False(await cut.InvokeAsync(() => cut.Instance.ReorderResourceAsync("api", "cache", after: false)));
        Assert.Equal(positions, Services.GetRequiredService<ResourceOrderStore>().GetPositions());
        await cut.InvokeAsync(reset);
        Assert.Equal(2, cut.FindAll("[data-resource-drag-handle]").Count);
        Assert.Equal("true", cut.Find(".resource-pane-list").GetAttribute("data-reorder-enabled"));
    }
 
    [Theory]
    [InlineData("Persistent", ResourceListDensity.Comfortable, "Container (persistent)")]
    [InlineData("Persistent", ResourceListDensity.Compact, "Container (persistent)")]
    [InlineData("Persistent", ResourceListDensity.Spacious, "Container (persistent)")]
    [InlineData("Session", ResourceListDensity.Comfortable, "Container")]
    [InlineData("Session", ResourceListDensity.Compact, "Container")]
    [InlineData("Session", ResourceListDensity.Spacious, "Container")]
    [InlineData(null, ResourceListDensity.Comfortable, "Container")]
    [InlineData(null, ResourceListDensity.Compact, "Container")]
    [InlineData(null, ResourceListDensity.Spacious, "Container")]
    public async Task AppHost_ContainerLifetimeQualifiesTypeAndBadgesResourceGlyph(string? lifetime, ResourceListDensity density, string expectedType)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("cache", state: KnownResourceState.Running, properties: lifetime is null ? [] : new()
            {
                [KnownProperties.Container.Lifetime] = new(KnownProperties.Container.Lifetime, Value.ForString(lifetime), false, null, 0, null, false)
            })], resourceChannelProvider: () => channel));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        await cut.InvokeAsync(() => Services.GetRequiredService<DashboardViewPreferences>().SetDensityAsync(density));
 
        var type = cut.Find("[data-resource-name='cache'] .resource-row-type");
        Assert.Equal(expectedType, type.TextContent);
        Assert.Equal(expectedType, type.GetAttribute("data-tooltip"));
        Assert.Equal($"Running; {expectedType}", cut.Find("[data-resource-name='cache'] .resource-row-link").GetAttribute("aria-description"));
        var badges = cut.FindAll("[data-resource-name='cache'] .resource-row-icon .persistent-container-badge");
        Assert.Equal(lifetime == "Persistent" ? 1 : 0, badges.Count);
        if (lifetime == "Persistent")
        {
            var badge = Assert.Single(badges);
            Assert.Equal("img", badge.GetAttribute("role"));
            const string description = "This container is persistent and won't be stopped when the app host is shut down.";
            Assert.Equal(description, badge.GetAttribute("aria-label"));
            Assert.Equal(description, badge.GetAttribute("data-tooltip"));
        }
        Assert.Empty(cut.FindAll(".persistent-container-icon"));
    }
 
    [Theory]
    [InlineData("Tags", new[] { "Backend", "Shared", "Untagged" })]
    [InlineData("ResourceType", new[] { "Container", "Project" })]
    [InlineData("State", new[] { "Exited", "Running", "Unknown" })]
    public async Task AppHost_GroupingSupportsSelectionCollapseSearchAndReset(string grouping, string[] expectedGroups)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, resourceType: "Project"),
                ModelTestHelpers.CreateResource("worker", state: KnownResourceState.Exited, resourceType: "Project"),
                ModelTestHelpers.CreateResource("cache", resourceType: "Container")
            ], resourceChannelProvider: () => channel));
        var tags = Services.GetRequiredService<ResourceTagStore>();
        tags.AddTag("api", "Backend");
        tags.AddTag("worker", "Backend");
        tags.AddTag("api", "Shared");
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.StructuredLogsUrl("api"));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        Assert.Empty(cut.FindAll(".resource-tag-group"));
 
        cut.Find("#resourcePaneGroupingButton").Click();
        var menu = cut.FindComponents<AspireMenuButton>().Single(button => button.Instance.MenuButtonId == "resourcePaneGroupingButton");
        Assert.Equal(["None", "Tags", "Resource type", "State"], menu.Instance.Items.Where(item => !item.IsHeader).Select(item => item.Text));
        Assert.All(menu.Instance.Items.Where(item => !item.IsHeader), item => Assert.Equal(MenuItemRole.Radio, item.Role));
        await cut.InvokeAsync(menu.Instance.Items.Single(item => item.Id == $"resource-grouping-{grouping}").OnClick!);
        Assert.Equal(expectedGroups, cut.FindAll(".resource-tag-group").Select(group => group.GetAttribute("aria-label")));
        Assert.Equal("http://localhost/structuredlogs/resource/api", navigation.Uri);
        Assert.Equal("3 resources", cut.Find(".resource-pane-foot > span").GetAttribute("aria-label"));
 
        var group = cut.FindAll(".resource-tag-group")[0];
        var groupName = group.GetAttribute("aria-label");
        var resourceNames = group.QuerySelectorAll(".resource-row-name").Select(name => name.TextContent.Trim()).ToList();
        Assert.NotEmpty(resourceNames);
        group.QuerySelector(".resource-row-toggle")!.Click();
        Assert.Empty(cut.FindAll(".resource-tag-group")[0].QuerySelectorAll(".resource-row"));
        cut.FindAll(".resource-tag-group")[0].QuerySelector(".resource-row-toggle")!.Click();
        Assert.Equal(resourceNames, cut.FindAll(".resource-tag-group")[0].QuerySelectorAll(".resource-row-name").Select(name => name.TextContent.Trim()));
 
        cut.FindAll(".resource-tag-group")[0].QuerySelector(".resource-tag-link")!.Click();
        cut.Render();
        Assert.Equal(resourceNames, cut.Instance.SelectedResourceNames);
        await cut.FindComponent<FluentTextInput>().InvokeAsync(() => cut.FindComponent<FluentTextInput>().Instance.ValueChanged.InvokeAsync(groupName));
        Assert.Equal(groupName, Assert.Single(cut.FindAll(".resource-tag-group")).GetAttribute("aria-label"));
        await cut.FindComponent<FluentTextInput>().InvokeAsync(() => cut.FindComponent<FluentTextInput>().Instance.ValueChanged.InvokeAsync(string.Empty));
 
        await cut.InvokeAsync(menu.Instance.Items.Single(item => item.Id == "resource-grouping-None").OnClick!);
        Assert.Empty(cut.FindAll(".resource-tag-group"));
        Assert.Equal(["cache", "api", "worker"], cut.FindAll(".resource-row:not(.resource-row-all) .resource-row-name").Select(name => name.TextContent.Trim()));
    }
 
    [Theory]
    [InlineData("Tags")]
    [InlineData("ResourceType")]
    [InlineData("State")]
    public async Task AppHost_GroupingIsPersistedIndependentlyOfPaneNavigation(string grouping)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")], resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneGrouping ? (true, grouping) : (false, null);
        string? storedGrouping = null;
        storage.OnSetUnprotectedAsync = (key, value) =>
        {
            if (key == BrowserStorageKeys.ResourcePaneGrouping)
            {
                storedGrouping = Assert.IsType<string>(value);
            }
        };
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.ResourceListUrl());
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".resource-tag-group")));
        navigation.NavigateTo(DashboardUrls.TelemetrySourcesUrl());
        cut.Render();
        Assert.Single(cut.FindAll("#resourcePaneGroupingButton"));
        Assert.Single(cut.FindAll(".resource-tag-group"));
        navigation.NavigateTo(DashboardUrls.ResourceListUrl());
        cut.Render();
        Assert.Single(cut.FindAll(".resource-tag-group"));
 
        cut.Find("#resourcePaneGroupingButton").Click();
        var menu = cut.FindComponents<AspireMenuButton>().Single(button => button.Instance.MenuButtonId == "resourcePaneGroupingButton");
        Assert.Equal($"resource-grouping-{grouping}", Assert.Single(menu.Instance.Items, item => item.Checked).Id);
        await cut.InvokeAsync(menu.Instance.Items.Single(item => item.Id == "resource-grouping-None").OnClick!);
        Assert.Equal("None", storedGrouping);
        Assert.Empty(cut.FindAll(".resource-tag-group"));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void AppHost_LegacyTagsViewSelectsAndPersistsTagGrouping(bool fromStorage)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")], resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => fromStorage && key == BrowserStorageKeys.ResourcePaneMode ? (true, "Tags") : (false, null);
        string? storedGrouping = null;
        storage.OnSetUnprotectedAsync = (key, value) =>
        {
            if (key == BrowserStorageKeys.ResourcePaneGrouping)
            {
                storedGrouping = Assert.IsType<string>(value);
            }
        };
        Services.GetRequiredService<ResourceTagStore>().AddTag("api", "Backend");
        Services.GetRequiredService<NavigationManager>().NavigateTo(fromStorage ? DashboardUrls.ResourceListUrl() : DashboardUrls.TagsUrl());
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() =>
        {
            Assert.Equal("Backend", Assert.Single(cut.FindAll(".resource-tag-group")).GetAttribute("aria-label"));
            Assert.Equal(ResourcePaneMode.Resources, Services.GetRequiredService<ResourcePaneState>().Mode);
            Assert.Equal("Tags", storedGrouping);
        });
    }
 
    [Fact]
    public async Task AppHost_StateGroupingCombinesUnknownStatesAndTracksResourceUpdates()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api"),
                ModelTestHelpers.CreateResource("worker", state: KnownResourceState.Unknown),
                ModelTestHelpers.CreateResource("hidden", state: KnownResourceState.Running, hidden: true),
                ModelTestHelpers.CreateResource("parameter", resourceType: KnownResourceTypes.Parameter)
            ], resourceChannelProvider: () => channel));
        var storage = Assert.IsType<TestLocalStorage>(Services.GetRequiredService<ILocalStorage>());
        storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneGrouping ? (true, "State") : (false, null);
        Services.GetRequiredService<NavigationManager>().NavigateTo(DashboardUrls.StructuredLogsUrl());
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() =>
        {
            var group = Assert.Single(cut.FindAll(".resource-tag-group"));
            Assert.Equal("Unknown", group.GetAttribute("aria-label"));
            Assert.Equal(["api", "worker"], group.QuerySelectorAll(".resource-row-name").Select(name => name.TextContent.Trim()));
        });
 
        await channel.Writer.WriteAsync([new ResourceViewModelChange(ResourceViewModelChangeType.Upsert,
            ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running))]);
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(["Unknown", "Running"], cut.FindAll(".resource-tag-group").Select(group => group.GetAttribute("aria-label")));
            Assert.Equal(["worker", "api"], cut.FindAll(".resource-tag-group .resource-row-name").Select(name => name.TextContent.Trim()));
        });
    }
 
    [Fact]
    public void AppHost_FilterButtonOpensPopoverAndShowsTooltipOnButton()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")], resourceChannelProvider: () => channel));
        Services.GetRequiredService<NavigationManager>().NavigateTo(DashboardUrls.ResourceListUrl());
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        var button = cut.Find("#resourcePaneFilterButton");
        Assert.Equal("Filters", button.GetAttribute("data-tooltip"));
        Assert.Equal("Filters", button.GetAttribute("aria-label"));
        Assert.Equal("false", button.GetAttribute("aria-expanded"));
        Assert.Empty(cut.FindAll(".resource-filter"));
        button.Click();
 
        Assert.Equal("true", cut.Find("#resourcePaneFilterButton").GetAttribute("aria-expanded"));
        Assert.Single(cut.FindAll(".resource-filter"));
        Assert.Equal(["Resource types", "State", "Health state", "Tags"], cut.FindAll(".resource-filter-group-name").Select(name => name.TextContent.Trim()));
    }
 
    [Theory]
    [InlineData("/structuredlogs")]
    [InlineData("/traces/detail/abc")]
    [InlineData("/metrics")]
    public async Task Standalone_RendersTelemetryResources(string path)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var client = new TestDashboardClient(isEnabled: false, whenConnected: Task.FromCanceled(new CancellationToken(canceled: true)));
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, client);
        await AddTelemetryAsync("api");
        await AddTelemetryAsync("worker");
        Services.GetRequiredService<ResourcePaneState>().SetMode(ResourcePaneMode.Tags);
        Services.GetRequiredService<NavigationManager>().NavigateTo(path);
 
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() =>
        {
            Assert.True(cut.Instance.IsLoaded);
            Assert.Equal(["api", "worker"], cut.Instance.GetAllResourceNames());
            Assert.Equal(3, cut.FindAll(".resource-row").Count);
            Assert.Equal("2 telemetry sources", cut.Find(".resource-pane-foot > span").GetAttribute("aria-label"));
        });
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Fact]
    public async Task Standalone_NewTelemetryAppearsAndSearchFiltersResources()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: false));
        Services.GetRequiredService<NavigationManager>().NavigateTo(DashboardUrls.StructuredLogsUrl());
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        await AddTelemetryAsync("api");
        await AddTelemetryAsync("worker");
        cut.WaitForAssertion(() => Assert.Equal(["api", "worker"], cut.Instance.GetAllResourceNames()));
 
        await cut.FindComponent<FluentTextInput>().InvokeAsync(() =>
            cut.FindComponent<FluentTextInput>().Instance.ValueChanged.InvokeAsync("WORK"));
 
        cut.WaitForAssertion(() =>
        {
            var links = cut.FindAll(".resource-row-link");
            Assert.Equal([DashboardUrls.StructuredLogsUrl(), DashboardUrls.StructuredLogsUrl("worker")],
                links.Select(link => link.GetAttribute("href")));
            Assert.Equal("1 telemetry source", cut.Find(".resource-pane-foot > span").GetAttribute("aria-label"));
            Assert.Equal(["api", "worker"], cut.Instance.GetAllResourceNames());
        });
    }
 
    [Fact]
    public async Task Standalone_SelectingResourcesPreservesSelectionInPageUrls()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: false));
        await AddTelemetryAsync("api");
        await AddTelemetryAsync("worker");
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.MetricsUrl("api"));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.Find(".resource-row-all a").Click();
        Assert.Equal("http://localhost/metrics", navigation.Uri);
        cut.Render();
        cut.FindAll(".resource-row-link").Single(link => link.GetAttribute("href") == DashboardUrls.MetricsUrl("api")).Click();
        cut.Render();
        cut.FindAll(".resource-row-link").Single(link => link.GetAttribute("href") == DashboardUrls.MetricsUrl("worker")).Click(new Microsoft.AspNetCore.Components.Web.MouseEventArgs { CtrlKey = true });
        cut.Render();
 
        Assert.Equal(["api", "worker"], cut.Instance.SelectedResourceNames);
        Assert.Equal(2, cut.Instance.GetSelectionTelemetryKeys(cut.Instance.SelectedResourceNames)?.Count);
        Assert.Equal("/traces?resource=api&resource=worker", cut.Instance.GetTabUrl(ResourceTab.Traces));
    }
 
    [Fact]
    public async Task Standalone_ModifierClickAfterSelectAll_DeselectsOnlyThatResource()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: false));
        await AddTelemetryAsync("api");
        await AddTelemetryAsync("frontend");
        await AddTelemetryAsync("worker");
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.MetricsUrl());
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Empty(cut.Instance.SelectedResourceNames));
 
        cut.FindAll(".resource-row-link").Single(link => link.GetAttribute("href") == DashboardUrls.MetricsUrl("frontend")).Click(new Microsoft.AspNetCore.Components.Web.MouseEventArgs { MetaKey = true });
        cut.Render();
 
        Assert.Equal(["api", "worker"], cut.Instance.SelectedResourceNames);
    }
 
    [Fact]
    public async Task Standalone_ChangingViewportPreservesLayoutAndRoute()
    {
        var desktop = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var mobile = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, desktop, new TestDashboardClient(isEnabled: false));
        await AddTelemetryAsync("api");
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.StructuredLogsUrl("api"));
        var cut = Render<CascadingValue<ViewportInformation>>(builder =>
            builder.Add(p => p.Value, desktop).AddChildContent<ResourcesLayout>());
        var layout = cut.FindComponent<ResourcesLayout>().Instance;
 
        cut.Render(builder => builder.Add(p => p.Value, mobile).AddChildContent<ResourcesLayout>());
        Assert.Same(layout, cut.FindComponent<ResourcesLayout>().Instance);
        Assert.Equal("http://localhost/structuredlogs/resource/api", navigation.Uri);
        cut.Find(".resource-pane-open-button").Click();
        Assert.True(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
 
        cut.Render(builder => builder.Add(p => p.Value, desktop).AddChildContent<ResourcesLayout>());
        Assert.Same(layout, cut.FindComponent<ResourcesLayout>().Instance);
        Assert.Equal("http://localhost/structuredlogs/resource/api", navigation.Uri);
    }
 
    [Fact]
    public async Task AppHost_TelemetryPanePreservesExternalSelectionAndNavigationContext()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var client = new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")], resourceChannelProvider: () => channel);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, client);
        await AddTelemetryAsync("api");
        await AddTelemetryAsync("browser");
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(DashboardUrls.StructuredLogsUrl());
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() =>
        {
            Assert.False(cut.Instance.IsTelemetryPane);
            Assert.Equal(["api"], cut.Instance.GetAllResourceNames());
            Assert.Equal(["api"], cut.Instance.GetSelectionTelemetryKeys(null)!.Select(key => key.Name));
            Assert.Equal([DashboardUrls.StructuredLogsUrl("api")],
                cut.FindAll(".resource-row-link").Select(link => link.GetAttribute("href")));
        });
 
        navigation.NavigateTo(DashboardUrls.TelemetrySourcesUrl());
        cut.Render();
        cut.WaitForAssertion(() =>
        {
            Assert.True(cut.Instance.IsTelemetryPane);
            Assert.Equal(["api"], cut.Instance.GetAllResourceNames());
            Assert.Equal(["browser"], cut.Instance.GetSelectionTelemetryKeys(null)!.Select(key => key.Name));
            Assert.Equal(["/structuredlogs/resource/api"],
                cut.FindAll(".resource-row-link").Select(link => link.GetAttribute("href")));
        });
        navigation.NavigateTo(DashboardUrls.AddTelemetryPane(DashboardUrls.TracesUrl("browser")));
        cut.Render();
        cut.WaitForAssertion(() =>
        {
            Assert.True(cut.Instance.IsTelemetryPane);
            Assert.Equal("/traces/resource/browser", cut.Instance.GetTabUrl(ResourceTab.Traces));
            Assert.Equal("/metrics/resource/browser?duration=15&pane=telemetry",
                cut.Instance.AddSelectionToUrl("/metrics/resource/browser?duration=15"));
            Assert.Null(cut.Instance.GetSelectionTelemetryKeys(null));
        });
 
        navigation.NavigateTo(DashboardUrls.TelemetrySourcesUrl());
        cut.Render();
        await AddTelemetryAsync("worker");
        cut.WaitForAssertion(() => Assert.Equal(["browser", "worker"],
            cut.Instance.GetSelectionTelemetryKeys(null)!.Select(key => key.Name)));
 
        navigation.NavigateTo(DashboardUrls.ResourceListUrl());
        cut.Render();
        cut.WaitForAssertion(() =>
        {
            Assert.False(cut.Instance.IsTelemetryPane);
            Assert.Equal(["api"], cut.Instance.GetAllResourceNames());
        });
    }
 
    [Fact]
    public void AppHost_EmptyTelemetryPaneStillShowsAppModelResources()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")], resourceChannelProvider: () => channel));
        Services.GetRequiredService<NavigationManager>().NavigateTo(DashboardUrls.TelemetrySourcesUrl());
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
        cut.WaitForAssertion(() =>
        {
            Assert.Empty(cut.FindAll(".resource-pane-message"));
            Assert.Equal(["api"], cut.Instance.GetAllResourceNames());
            Assert.Empty(cut.Instance.GetSelectionTelemetryKeys(null)!);
            Assert.Equal(3, cut.FindAll(".resource-kind-toggle").Count);
        });
    }
 
    [Fact]
    public async Task AppHost_DirectTelemetrySourceLinkSelectsTelemetryPane()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")], resourceChannelProvider: () => channel));
        await AddTelemetryAsync("browser");
        Services.GetRequiredService<NavigationManager>().NavigateTo(DashboardUrls.TracesUrl("browser"));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() =>
        {
            Assert.True(cut.Instance.IsTelemetryPane);
            Assert.Equal(["api"], cut.Instance.GetAllResourceNames());
            Assert.Equal("/traces/detail/abc?pane=telemetry", cut.Instance.AddPaneToUrl("/traces/detail/abc"));
        });
    }
 
    [Fact]
    public void AppHost_StillSubscribesToAppModelResources()
    {
        var viewport = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var client = new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("api")], resourceChannelProvider: () => channel);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, client);
        Services.GetRequiredService<NavigationManager>().NavigateTo(DashboardUrls.StructuredLogsUrl("api"));
        var cut = Render<ResourcesLayout>(builder => builder.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() =>
        {
            Assert.True(cut.Instance.IsLoaded);
            Assert.Equal(["api"], cut.Instance.GetAllResourceNames());
        });
        Assert.Equal(1, client.ResourceSubscriptionCount);
        cut.Find(".resource-pane-open-button").Click();
        Assert.True(cut.Find(".resources-layout").ClassList.Contains("drawer-open"));
    }
 
    private Task AddTelemetryAsync(string name)
    {
        var resource = CreateResource(name);
        resource.Attributes.Remove(resource.Attributes.Single(attribute => attribute.Key == OtlpResource.SERVICE_INSTANCE_ID));
        return Services.GetRequiredService<SqliteTelemetryRepository>().AddLogsAsync(new AddContext(),
        [
            new ResourceLogs
            {
                Resource = resource,
                ScopeLogs = { new ScopeLogs { Scope = CreateScope(), LogRecords = { CreateLogRecord() } } }
            }
        ]);
    }
}