File: Integration\Playwright\ResourcesTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.Dashboard.Resources;
using Aspire.TestUtilities;
using Aspire.Tests.Shared.DashboardModel;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Playwright;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Integration.Playwright;
 
[RequiresFeature(TestFeature.Playwright)]
public class ResourcesTests : PlaywrightTestsBase<ResourcesTests.ResourcesDashboardServerFixture>
{
    public ResourcesTests(ResourcesDashboardServerFixture dashboardServerFixture)
        : base(dashboardServerFixture)
    {
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ViewOptionsMenu_ReportsExpandedState()
    {
        await RunTestAsync(async page =>
        {
            await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout();
 
            var viewOptionsButton = page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Dashboard.Resources.Resources.ResourcesChangeViewOptions, Exact = true });
            await Assertions.Expect(viewOptionsButton).ToHaveAttributeAsync("aria-expanded", "false");
 
            await viewOptionsButton.ClickAsync();
            await Assertions.Expect(viewOptionsButton).ToHaveAttributeAsync("aria-expanded", "true");
 
            var showResourceTypes = page.GetByRole(AriaRole.Menuitem, new PageGetByRoleOptions { Name = Dashboard.Resources.Resources.ResourcesShowTypes, Exact = true });
            await showResourceTypes.ClickAsync();
            await Assertions.Expect(viewOptionsButton).ToHaveAttributeAsync("aria-expanded", "false");
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task UrlLink_EnterDoesNotOpenResourceDetails()
    {
        await RunTestAsync(async page =>
        {
            await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout();
 
            var popup = await page.RunAndWaitForPopupAsync(async () =>
            {
                var urlLink = page.GetByRole(AriaRole.Link, new PageGetByRoleOptions { Name = "about:blank#resource-url" }).First;
                await urlLink.FocusAsync();
                await page.Keyboard.PressAsync("Enter");
            });
 
            await popup.WaitForURLAsync("about:blank#resource-url").DefaultTimeout();
            await popup.CloseAsync();
            await Assertions.Expect(page.Locator(".details-header-title")).ToHaveCountAsync(0);
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task GridActionButtons_UseCompactMinimumWidth()
    {
        await RunTestAsync(async page =>
        {
            await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout();
 
            var values = await page.Locator(".grid-action-container fluent-button").EvaluateAllAsync<int[]>(
                """
                buttons => [
                    buttons.length,
                    buttons.filter(button => getComputedStyle(button).minWidth !== '32px').length
                ]
                """);
 
            Assert.True(values[0] > 0);
            Assert.Equal(0, values[1]);
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task NameColumn_SortsResources()
    {
        await RunTestAsync(async page =>
        {
            await page.GotoAsync("/");
 
            var resourceNames = page.Locator(".main-grid .resource-row .resource-name-text");
            await Assertions.Expect(resourceNames).ToHaveCountAsync(3);
            await Assertions.Expect(resourceNames.Nth(0)).ToContainTextAsync("basketcache");
            await Assertions.Expect(resourceNames.Nth(1)).ToContainTextAsync("apigateway");
            await Assertions.Expect(resourceNames.Nth(2)).ToContainTextAsync("TestResource");
 
            var nameHeader = page.Locator(".main-grid th[col-index='1']");
            await nameHeader.GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = ControlsStrings.NameColumnHeader, Exact = true }).ClickAsync();
 
            var sortItem = page.GetByRole(AriaRole.Menuitem, new PageGetByRoleOptions { Name = ControlsStrings.FluentDataGridHeaderCellSortButtonText, Exact = true });
            await sortItem.ClickAsync();
 
            await Assertions.Expect(nameHeader).ToHaveAttributeAsync("aria-sort", "ascending");
            await Assertions.Expect(resourceNames.Nth(0)).ToContainTextAsync("apigateway");
            await Assertions.Expect(resourceNames.Nth(1)).ToContainTextAsync("basketcache");
            await Assertions.Expect(resourceNames.Nth(2)).ToContainTextAsync("TestResource");
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ResourceViewTabs_RemainVisibleAtNarrowViewport()
    {
        await RunTestAsync(async page =>
        {
            await page.SetViewportSizeAsync(320, 720);
            await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout();
 
            var tableTab = page.GetByRole(AriaRole.Tab, new PageGetByRoleOptions { Name = ControlsStrings.ResourcesContainerTableTab, Exact = true });
            await Assertions.Expect(tableTab).ToBeVisibleAsync();
            await Assertions.Expect(tableTab).ToHaveAttributeAsync("aria-selected", "true");
 
            var tabBounds = await tableTab.BoundingBoxAsync();
            Assert.NotNull(tabBounds);
            Assert.True(tabBounds.X >= 0);
            Assert.True(tabBounds.X + tabBounds.Width <= 320);
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ResourceViewTabs_RemainVisibleAtNarrowHorizontalViewport()
    {
        await RunTestAsync(async page =>
        {
            await page.SetViewportSizeAsync(360, 720);
            await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout();
 
            var tabs = page.Locator(".resources-tab-header[orientation='horizontal']");
            await Assertions.Expect(tabs).ToBeVisibleAsync();
 
            var tableTab = page.GetByRole(AriaRole.Tab, new PageGetByRoleOptions { Name = ControlsStrings.ResourcesContainerTableTab, Exact = true });
            var parametersTab = page.GetByRole(AriaRole.Tab, new PageGetByRoleOptions { Name = ControlsStrings.ResourcesContainerParametersTab, Exact = true });
            var graphTab = page.Locator("#tab-Graph");
 
            await AssertTabVisibleWithinViewportAsync(tableTab, 360);
            await AssertTabVisibleWithinViewportAsync(parametersTab, 360);
            await AssertTabVisibleWithinViewportAsync(graphTab, 360);
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task GraphView_SwitchesWithoutReloadOrLayoutCollapse()
    {
        await RunTestAsync(async page =>
        {
            await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout();
 
            var navigationCount = await page.EvaluateAsync<int>("() => performance.getEntriesByType('navigation').length");
 
            var graphTab = page.Locator("#tab-Graph");
            await graphTab.ClickAsync();
            await Assertions.Expect(graphTab).ToHaveAttributeAsync("aria-selected", "true");
 
            var graphContainer = page.Locator("#resourcesGraphContainer");
            await Assertions.Expect(graphContainer).ToBeVisibleAsync();
 
            var graphWidth = await graphContainer.EvaluateAsync<double>("element => element.getBoundingClientRect().width");
            Assert.True(graphWidth > 100, $"The resource graph should fill the page content area, but its width was {graphWidth}px.");
 
            var navigationCountAfterSwitch = await page.EvaluateAsync<int>("() => performance.getEntriesByType('navigation').length");
            Assert.Equal(navigationCount, navigationCountAfterSwitch);
            await Assertions.Expect(page.Locator("#blazor-error-ui")).ToBeHiddenAsync();
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ResourceGraphCog_IsKeyboardAccessibleAndDoesNotDragNode()
    {
        await RunTestAsync(async page =>
        {
            await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout();
            await page.Locator("#tab-Graph").ClickAsync();
 
            var node = page.Locator(".resource-group[resource-name='TestResource']");
            await Assertions.Expect(node).ToBeVisibleAsync();
            await node.HoverAsync();
 
            var resourceActionsLabel = string.Format(
                Dashboard.Resources.Resources.ResourcesGraphResourceActionsButton,
                "TestResource");
            var otherResourceActionsLabel = string.Format(
                Dashboard.Resources.Resources.ResourcesGraphResourceActionsButton,
                "apigateway");
            var cog = node.GetByRole(
                AriaRole.Button,
                new LocatorGetByRoleOptions
                {
                    Name = resourceActionsLabel,
                    Exact = true
                });
            await Assertions.Expect(cog).ToBeVisibleAsync();
            await Assertions.Expect(page.GetByRole(
                AriaRole.Button,
                new PageGetByRoleOptions
                {
                    Name = otherResourceActionsLabel,
                    Exact = true
                })).ToHaveCountAsync(1);
 
            // Attempt a large drag from the cog. D3's drag behavior is attached to the ancestor
            // resource group, so this verifies the cog stops the initiating pointer event.
            await page.WaitForTimeoutAsync(300);
            var nodeBoundsBefore = await node.BoundingBoxAsync();
            var cogBounds = await cog.BoundingBoxAsync();
            Assert.NotNull(nodeBoundsBefore);
            Assert.NotNull(cogBounds);
 
            await page.Mouse.MoveAsync(
                cogBounds.X + cogBounds.Width / 2,
                cogBounds.Y + cogBounds.Height / 2);
            await page.Mouse.DownAsync();
            Assert.Equal(
                "none",
                await cog.EvaluateAsync<string>("element => getComputedStyle(element).outlineStyle"));
            await page.Mouse.MoveAsync(
                cogBounds.X + cogBounds.Width / 2 + 80,
                cogBounds.Y + cogBounds.Height / 2 + 80,
                new MouseMoveOptions { Steps = 5 });
            await page.Mouse.UpAsync();
 
            var nodeBoundsAfter = await node.BoundingBoxAsync();
            Assert.NotNull(nodeBoundsAfter);
            Assert.InRange(Math.Abs(nodeBoundsAfter.X - nodeBoundsBefore.X), 0, 5);
            Assert.InRange(Math.Abs(nodeBoundsAfter.Y - nodeBoundsBefore.Y), 0, 5);
            Assert.False((await node.GetAttributeAsync("class"))?.Split(' ').Contains("resource-group-selected"));
 
            var menu = page.GetByRole(
                AriaRole.Menu,
                new PageGetByRoleOptions { Name = "TestResource", Exact = true });
            await Assertions.Expect(menu).ToBeHiddenAsync();
 
            await node.HoverAsync();
            await cog.ClickAsync();
            await Assertions.Expect(menu).ToBeVisibleAsync();
            await Assertions.Expect(cog).ToHaveAttributeAsync("aria-expanded", "true");
            Assert.False((await node.GetAttributeAsync("class"))?.Split(' ').Contains("resource-group-selected"));
 
            await page.Keyboard.PressAsync("Escape");
            await Assertions.Expect(menu).ToBeHiddenAsync();
            await Assertions.Expect(cog).ToHaveAttributeAsync("aria-expanded", "false");
 
            await node.HoverAsync();
            await cog.FocusAsync();
            await page.Keyboard.PressAsync("Enter");
 
            await Assertions.Expect(menu).ToBeVisibleAsync();
            await Assertions.Expect(cog).ToHaveAttributeAsync("aria-haspopup", "menu");
            await Assertions.Expect(cog).ToHaveAttributeAsync("aria-expanded", "true");
            var header = menu.Locator(".aspire-menu-header");
            await Assertions.Expect(header.Locator(".aspire-menu-header-text")).ToHaveTextAsync("TestResource");
            var headerBounds = await header.BoundingBoxAsync();
            Assert.NotNull(headerBounds);
            Assert.InRange(headerBounds.Height, 39, 41);
 
            await page.Keyboard.PressAsync("Escape");
            await Assertions.Expect(menu).ToBeHiddenAsync();
            await Assertions.Expect(cog).ToHaveAttributeAsync("aria-expanded", "false");
            await Assertions.Expect(cog).ToBeFocusedAsync();
 
            await page.Keyboard.PressAsync("Enter");
            await page.GetByRole(
                AriaRole.Menuitem,
                new PageGetByRoleOptions
                {
                    Name = ControlsStrings.ActionViewDetailsText,
                    Exact = true
                }).ClickAsync();
            await Assertions.Expect(page.Locator(".details-header-title")).ToHaveTextAsync("TestResource");
 
            await page.GetByRole(
                AriaRole.Button,
                new PageGetByRoleOptions
                {
                    Name = ControlsStrings.SummaryDetailsViewCloseView,
                    Exact = true
                }).ClickAsync();
            await Assertions.Expect(page.Locator(".details-header-title")).ToHaveCountAsync(0);
            await Assertions.Expect(cog).ToBeFocusedAsync();
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task GraphNode_RightClickOpensMenuAtCursorWithoutOverlay()
    {
        await RunTestAsync(async page =>
        {
            await page.GotoAsync("/?view=Graph");
 
            var node = page.Locator(".resource-node").First;
            await Assertions.Expect(node).ToBeVisibleAsync();
            var nodeBounds = await node.BoundingBoxAsync();
            Assert.NotNull(nodeBounds);
 
            var cursorX = nodeBounds.X + nodeBounds.Width / 2;
            var cursorY = nodeBounds.Y + nodeBounds.Height / 2;
            await node.Locator("xpath=..").EvaluateAsync(
                """
                element => {
                    const bounds = element.querySelector('.resource-node').getBoundingClientRect();
                    element.dispatchEvent(new MouseEvent('contextmenu', {
                        bubbles: true,
                        button: 2,
                        clientX: Math.round(bounds.left + bounds.width / 2),
                        clientY: Math.round(bounds.top + bounds.height / 2)
                    }));
                }
                """);
 
            var menu = page.Locator("fluent-menu.aspire-menu-container:not([trigger]) > fluent-menu-list");
            await Assertions.Expect(menu).ToBeVisibleAsync();
            var menuBounds = await menu.BoundingBoxAsync();
            Assert.NotNull(menuBounds);
 
            Assert.InRange(Math.Abs(menuBounds.X - cursorX), 0, 2);
            Assert.InRange(Math.Abs(menuBounds.Y - cursorY), 0, 2);
 
            var visibleBlockingIndicators = await page.Locator("fluent-overlay, .aspire-progress-ring").EvaluateAllAsync<int>(
                "elements => elements.filter(element => element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0).length");
            Assert.Equal(0, visibleBlockingIndicators);
 
            await page.Keyboard.PressAsync("Escape");
            await Assertions.Expect(menu).ToBeHiddenAsync();
 
            await node.Locator("xpath=..").EvaluateAsync(
                """
                element => {
                    const bounds = element.querySelector('.resource-node').getBoundingClientRect();
                    element.dispatchEvent(new MouseEvent('contextmenu', {
                        bubbles: true,
                        button: 2,
                        clientX: Math.round(bounds.left + bounds.width / 2),
                        clientY: Math.round(bounds.top + bounds.height / 2)
                    }));
                }
                """);
 
            await Assertions.Expect(menu).ToBeVisibleAsync();
        });
    }
 
    public sealed class ResourcesDashboardServerFixture : DashboardServerFixture
    {
        protected override IReadOnlyList<ResourceViewModel> Resources =>
        [
            ModelTestHelpers.CreateResource(
                resourceName: "apigateway",
                resourceType: KnownResourceTypes.Project,
                state: KnownResourceState.Running),
            ModelTestHelpers.CreateResource(
                resourceName: "basketcache",
                resourceType: KnownResourceTypes.Container,
                state: KnownResourceState.Running),
            ModelTestHelpers.CreateResource(
                resourceName: "TestResource",
                resourceType: KnownResourceTypes.Project,
                state: KnownResourceState.Running,
                urls:
                [
                    new UrlViewModel("http", new Uri("about:blank#resource-url"), isInternal: false, isInactive: false, UrlDisplayPropertiesViewModel.Empty)
                ])
        ];
    }
 
    private static async Task AssertTabVisibleWithinViewportAsync(ILocator tab, int viewportWidth)
    {
        await Assertions.Expect(tab).ToBeVisibleAsync();
 
        var tabBounds = await tab.BoundingBoxAsync();
        Assert.NotNull(tabBounds);
        Assert.True(tabBounds.X >= 0, $"Tab should be within the viewport, but its X position was {tabBounds.X}.");
        Assert.True(tabBounds.X + tabBounds.Width <= viewportWidth, $"Tab should fit inside the {viewportWidth}px viewport, but its right edge was {tabBounds.X + tabBounds.Width}.");
    }
}