diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor index 39f66983a47..f6e3d796052 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor @@ -18,7 +18,7 @@ var highlightedCommand = _highlightedCommands[i]; - + } @@ -29,7 +29,7 @@ var outputAction = ResourceMenuBuilder.CreateViewOutputMenuItem(Resource, ResourceByName); } - + diff --git a/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor b/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor new file mode 100644 index 00000000000..707dc376e6b --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor @@ -0,0 +1,28 @@ +@namespace Aspire.Dashboard.Components +@using Aspire.Dashboard.Utils + +@* The rich preview the trace page's log markers show in their tooltip. The page renders one, filled with the marker + the pointer or keyboard is on, rather than one per marker: a span can have thousands of logs, and each preview is a + small table. The shared tooltip clones it when it opens and follows its changes while it's open. *@ + diff --git a/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor.cs b/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor.cs new file mode 100644 index 00000000000..cf9803cadd5 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor.cs @@ -0,0 +1,45 @@ +// 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.Otlp.Storage; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; + +namespace Aspire.Dashboard.Components; + +public sealed partial class SpanLogPreview : ComponentBase +{ + /// + /// The id of the preview's element, which each log marker names in its data-tooltip-content attribute. + /// + public const string ElementId = "span-log-preview"; + + private LogSummary? _logEntry; + + [Inject] + public required BrowserTimeProvider TimeProvider { get; init; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + /// + /// Fills the preview with a log marker's entry, when the pointer or keyboard reaches the marker. + /// + /// + /// The marker's handler targets this component, not the page, so only the preview renders again, not the trace's + /// whole waterfall. + /// + public void Show(LogSummary logEntry) + { + if (_logEntry?.InternalId == logEntry.InternalId) + { + return; + } + + _logEntry = logEntry; + StateHasChanged(); + } + + internal LogSummary? LogEntry => _logEntry; +} diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor index 17b40fe157d..d352fe9057b 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor @@ -344,6 +344,7 @@ data-pane-location="@_lastLocation" data-pane-interactions-ready="@(_isOrderingRegistered ? "true" : "false")" data-home-pending="@(_homeNavigation is not null ? "true" : null)" + data-escape-home="@(CanEscapeGoHome ? "true" : null)" style="@GetLayoutStyle()" @ref="_layoutElement"> @* A single header keeps section subscriptions stable across viewport changes. *@ @@ -505,6 +506,9 @@ } + @* Resolved icons are shared. Set the component color rather than mutating the cached icon with WithColor. + Every action icon takes its button's text color, labeled or not, so an icon reads as part of its button + and dims with it when the button is disabled. *@ @foreach (var command in showLabels ? GetHighlightedCommands(resource) : _isPaneFullCanvas ? GetFullPageRowCommands(resource) : GetSidebarCommands(resource)) { var commandTitle = !string.IsNullOrEmpty(command.GetDisplayDescription()) ? command.GetDisplayDescription() : command.GetDisplayName(); @@ -512,9 +516,9 @@ { + @command.GetDisplayName() } @@ -523,12 +527,13 @@ + Disabled="@IsCommandDisabled(resource, command)"> + + } } @* Every row has its menu, parameters included: it holds what the row's buttons don't, such as Delete @@ -536,8 +541,6 @@ diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs index 41e5889b1c6..ebc59894a2e 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs @@ -592,6 +592,14 @@ protected override async Task OnParametersSetAsync() } } + // Whether Escape, when nothing else uses it, goes to the dashboard's home as the brand does (see + // registerResourceContextMenu in ResourcesLayout.razor.js). Every page beside the docked list can go there. Home + // itself can't, nor a narrow layout, where "/" redirects to the overview because there's no full-page list, nor a + // dashboard without the resource service, whose "/" redirects to the structured logs (see Home). While home is + // loading, Escape would only start the same navigation again. + internal bool CanEscapeGoHome => + ViewportInformation.IsDesktop && DashboardClient.IsEnabled && !_isPaneFullCanvas && _homeNavigation is null; + internal void OnHomeNavigationStarting() { // Home is already showing, so the navigation won't change the location, and nothing would release a tab diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css index 7ec21cfc50b..53bca5855b9 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css @@ -131,7 +131,7 @@ margin: 6px 0 0; position-try-fallbacks: flip-inline; box-sizing: border-box; - width: min(var(--resource-pane-expanded-width, 290px), calc(100vw - 24px)); + width: min(330px, calc(100vw - 24px)); max-width: none; height: max-content; max-height: stretch; @@ -2784,26 +2784,31 @@ } } -/* Compact sidebar rows show their actions only while the row is hovered or focused, or while one of its menus is +/* Compact sidebar rows show their actions only while the row is hovered or has keyboard-visible focus, or while one of its menus is open, and then in place of the type and state columns. The rest of the time the action rail is hidden, so the name gets its room. The swap keeps the name's width about the same: the 92px the rail and its gap gain is about the 90px the columns give up. An open menu is matched by aria-haspopup as well as aria-expanded: a parent's expand toggle also sets aria-expanded="true", and that must not keep its actions showing. Only the attribute's presence is tested because Fluent's menu changes the button's aria-haspopup from "menu" to "true" while the menu is open. + Mouse selection leaves the row link focused, so :focus-within would keep the actions visible after the pointer leaves. + Keep hidden actions tabbable so moving focus from the row link to an action reveals the rail without losing focus. Touch screens can't hover, so (hover: hover) leaves their actions always visible. The expanded list (the full canvas) is excluded and keeps its fixed, always-visible action slots. */ @media (hover: hover) { - .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) { + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) { grid-template-columns: minmax(0, 1fr); column-gap: 0; } - .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-actions { - display: none; + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-actions { + position: absolute; + inset-inline-end: 0; + opacity: 0; + pointer-events: none; } - .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-meta { + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-meta { display: none; } @@ -2827,13 +2832,22 @@ margin-inline-end: calc(var(--resource-row-error-offset) - var(--resource-row-meta-type-width) - 34px); } - .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-error-link { + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-error-link { margin-inline-end: calc(var(--resource-row-error-offset) - 92px); } - .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .error-badge-spacer { + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .error-badge-spacer { margin-inline-end: calc(var(--resource-row-error-offset) - 100px); } + + /* At rest the rows' actions are hidden and their state dots form a column at the end of the list, so the group + header's chevron is centered over that column instead of floating just past it. The row link ends 12px before + the row and has 8px of end padding, so the 10px dot's center is 25px from the end; the header ends at the same + line, and the 20px chevron needs 15px after it to center there. The count badge before it moves with it. + Touch screens keep the actions showing, so they have no dot column there and the chevron keeps its place. */ + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-tag-header > .resource-row-toggle { + margin-inline-end: 15px; + } } .resource-tree-guide, diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js index 5bb47ef1d2a..7abfce7163f 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js @@ -297,6 +297,14 @@ export function registerTabOverflow(registrationId, dotNetRef) { schedule(); } +// Stops the strips from moving focus to the tab that last had it when they're re-created. The tab remembered on +// focusin isn't forgotten when it's blurred, so a caller that takes focus off a tab on purpose clears it here. +function forgetTabFocus() { + for (const state of tabOverflowRegistrations.values()) { + state.focusedTab = null; + } +} + export function unregisterTabOverflow(registrationId) { const state = tabOverflowRegistrations.get(registrationId); if (!state) { @@ -834,6 +842,81 @@ export function registerResourceContextMenu(layoutElement, dotNetRef) { event.target.click(); } }; + // Escape goes to the dashboard's home, the table of every resource, as the header's brand does, when nothing else + // uses the key. Pages used to have a close button that led there, and Escape stayed as its shortcut when the button + // went. The layout says when the key may go home (data-escape-home): not at home itself, not on narrow layouts, + // which have no table, and not while home is already loading. + // Menus, popovers and dialogs close themselves with Escape, and text fields and the terminal keep it. Some of them + // close in their own keydown handler, so whether the key is free is decided in the capture phase, before any of + // them runs, and the brand is clicked in the bubble phase, only if no handler took the key (preventDefault). The + // capture listener is on the window, which comes before the document: the tooltip provider removes a shown tooltip + // in its own capture listener on the document. + let escapeIsFree = false; + const escapeOwnerSelector = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], .xterm, ' + + '[popover], fluent-popover-b, fluent-menu, fluent-dialog, dialog, [role="dialog"], [role="menu"], [role="listbox"], [role="combobox"], [role="textbox"]'; + const hasOpenOverlay = () => { + // A control whose popup is open says so (aria-haspopup with aria-expanded="true"), whatever the popup is built + // with: the filter button's popover isn't a native popover, and focus stays on the button after clicking it. + // A tree toggle has aria-expanded but no popup, so it doesn't count. + if (document.querySelector('[aria-haspopup]:not([aria-haspopup="false"])[aria-expanded="true"]')) { + return true; + } + // A shown tooltip is dismissed by Escape (see app-tooltips.js), and the same key mustn't also leave the page. + // https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html + if (document.querySelector('aspire-tooltip-provider fluent-tooltip:not([aria-hidden="true"])')) { + return true; + } + // A FluentPopover's dialog is in its shadow root, out of reach of the query below, and the button opening it + // doesn't always say so, like the URLs column's +N button. Its host says whether it's open: + // + if (document.querySelector('fluent-popover-b[opened="true"]')) { + return true; + } + try { + // Hidden tooltips can stay in the popover layer, so they're left to the check above. + return [...document.querySelectorAll(':popover-open, dialog[open]')] + .some(element => element.localName !== 'fluent-tooltip' && !element.closest('aspire-tooltip-provider')); + } + catch { + // Browsers without the popover API have no open popovers to protect. + return false; + } + }; + const onEscapeCapture = event => { + escapeIsFree = event.key === 'Escape' && !event.isComposing && !event.repeat && + !event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && + !event.composedPath().some(target => target instanceof Element && target.matches(escapeOwnerSelector)) && + !hasOpenOverlay(); + }; + const onEscape = event => { + const isFree = escapeIsFree; + escapeIsFree = false; + // Fluent's tab list cancels the keys it receives, Escape included, without doing anything with Escape. So a + // focused tab, for example the one just clicked to open the page, doesn't keep Escape from going home. + const isFromTabList = event.composedPath().some(target => target instanceof Element && target.matches('[role="tablist"]')); + if (!isFree || (event.defaultPrevented && !isFromTabList) || layoutElement.dataset.escapeHome !== 'true') { + return; + } + // Clicking the brand's link, rather than navigating here, keeps one way home: the brand clears the stored + // selection, holds a tab chosen while home loads, and grows the list across the page (see + // MainLayout.GoToAllResourcesAsync). The brand is in the page header, outside the layout element. + const home = document.querySelector('header .brand-home a.application-name'); + if (home && home.getClientRects().length > 0 && !home.closest('[inert]')) { + event.preventDefault(); + // A tab that has focus, such as one moved to with the keyboard, would get it back in the strip re-created + // at home (see registerTabOverflow), and since a key was pressed last, it would show its keyboard focus + // ring there. That restore is for a tab about to be chosen while home lands; Escape leaves the tab instead. + const focusedTab = document.activeElement instanceof Element + ? document.activeElement.closest('.resources-tab-header fluent-tab') + : null; + if (focusedTab) { + forgetTabFocus(); + focusedTab.blur(); + } + home.click(); + } + }; + // While the resource list fills the page, no tab is shown selected, and choosing the current tab opens its content. // The tab list can't report that choice (see OpenTabFromFullCanvasAsync), so a click, Enter or Space on a tab is // passed on. The tabs are in the page header, outside the layout, so this listens on the document. @@ -866,6 +949,8 @@ export function registerResourceContextMenu(layoutElement, dotNetRef) { document.addEventListener('keydown', onTabUserActivation, true); layoutElement.addEventListener('mousedown', onShiftPointerDown, true); document.addEventListener('keydown', onContextMenuDismiss, true); + window.addEventListener('keydown', onEscapeCapture, true); + document.addEventListener('keydown', onEscape); document.addEventListener('click', onTabActivate); document.addEventListener('keydown', onTabActivate); layoutElement.addEventListener('click', onClick); @@ -880,6 +965,8 @@ export function registerResourceContextMenu(layoutElement, dotNetRef) { document.removeEventListener('keydown', onTabUserActivation, true); layoutElement.removeEventListener('mousedown', onShiftPointerDown, true); document.removeEventListener('keydown', onContextMenuDismiss, true); + window.removeEventListener('keydown', onEscapeCapture, true); + document.removeEventListener('keydown', onEscape); document.removeEventListener('click', onTabActivate); document.removeEventListener('keydown', onTabActivate); layoutElement.removeEventListener('click', onClick); diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor index 9c084c8e826..25c117ddcd6 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor @@ -267,39 +267,20 @@ @foreach (var item in context.SpanLogs) { var buttonId = $"{context.Span.SpanId}-{item.LogEntry.InternalId}"; - var tooltipId = $"{buttonId}-tooltip"; - var eventName = item.LogEntry.EventName ?? StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsEntryDetails)]; var isSelected = SelectedData?.LogEntryViewModel?.LogEntry.InternalId == item.LogEntry.InternalId; var buttonColor = item.LogEntry.IsError ? "var(--error)" : spanColor; - }
@@ -366,6 +347,8 @@ @string.Format(Loc[nameof(Dashboard.Resources.TraceDetail.TraceDetailTraceNotFound)], TraceId) } + @* The one preview every log marker's tooltip shows (see SpanLogPreview). *@ + @code { diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index 8f3a28a8a0d..05b8251a55d 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -557,6 +557,13 @@ private async Task ExpandAllSpansAsync() private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app, _resources); + private SpanLogPreview? _logPreview; + + // Fills the shared preview when the pointer or keyboard reaches a log marker. The callback's receiver is the + // preview, so only it renders again: the page's handlers would render the whole waterfall on every hover. + private EventCallback ShowLogPreview(LogSummary logEntry) => + EventCallback.Factory.Create(_logPreview ?? (object)this, () => _logPreview?.Show(logEntry)); + private async Task ToggleSpanLogsAsync(LogSummary logEntry) { if (PageViewModel.SelectedData?.LogEntryViewModel?.LogEntry.InternalId == logEntry.InternalId) diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index 37ad6cdf871..b9b1f0ccf89 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -23,6 +23,10 @@ internal static class TerminalWebSocketProxy private static readonly TimeSpan s_handshakeTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan s_sendTimeout = TimeSpan.FromMinutes(2); private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(2); + // Waiting for the browser's Close reply delays only resource cleanup, not the + // status the browser already received. Aborting too early resets the TCP + // connection under the browser's in-flight reply, so allow a generous grace. + private static readonly TimeSpan s_closeHandshakeTimeout = TimeSpan.FromSeconds(10); public static void MapTerminalWebSocket(this WebApplication app) { @@ -495,7 +499,7 @@ private static async Task CloseAsync(WebSocket socket, WebSocketCloseStatus stat return; } - using var timeout = new CancellationTokenSource(s_closeTimeout); + using var timeout = new CancellationTokenSource(s_closeHandshakeTimeout); try { if (receive is null) diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/SpanLogPreviewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/SpanLogPreviewTests.cs new file mode 100644 index 00000000000..4ff9c5a79b4 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/SpanLogPreviewTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.Utils; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +[UseCulture("en-US")] +public class SpanLogPreviewTests : DashboardTestContext +{ + [Theory] + [InlineData("Test event", "Test event")] + [InlineData(null, "Log entry details")] + public async Task Show_FillsThePreviewWithTheMarkersEntry(string? eventName, string expectedEventName) + { + Services.AddLocalization(); + Services.AddSingleton(); + var cut = Render(); + + // Nothing is shown until a marker is reached, so the page holds one empty preview however many logs it has. + var preview = cut.Find($"#{SpanLogPreview.ElementId}"); + Assert.True(preview.HasAttribute("hidden")); + Assert.Empty(preview.Children); + + var time = new DateTime(2026, 11, 13, 10, 0, 0, DateTimeKind.Utc); + foreach (var (id, message) in new[] { (1L, "Test \nwith details"), (2L, "Second message") }) + { + var entry = CreateLogSummary(id, eventName, message, time.AddSeconds(id)); + await cut.InvokeAsync(() => cut.Instance.Show(entry)); + + preview = cut.Find($"#{SpanLogPreview.ElementId}"); + Assert.True(preview.HasAttribute("hidden")); + Assert.Equal(["DIV", "TABLE"], preview.Children.Select(child => child.TagName)); + Assert.Equal(expectedEventName, preview.QuerySelector(".log-tooltip-title")!.TextContent); + Assert.Equal("test-scope", preview.QuerySelector(".log-tooltip-subtitle")!.TextContent); + Assert.Collection(preview.QuerySelectorAll("tr"), + row => Assert.Equal(["Level", "Information"], row.QuerySelectorAll("td").Select(cell => cell.TextContent)), + row => Assert.Equal(["Timestamp", FormatHelpers.FormatTimeWithOptionalDate(Services.GetRequiredService(), time.AddSeconds(id), MillisecondsDisplay.Truncated)], row.QuerySelectorAll("td").Select(cell => cell.TextContent)), + row => Assert.Equal(["Message", message], row.QuerySelectorAll("td").Select(cell => cell.TextContent))); + } + } + + private static LogSummary CreateLogSummary(long id, string? eventName, string message, DateTime time) + { + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + return new LogSummary + { + InternalId = id, + TimeStamp = time, + Severity = LogLevel.Information, + Message = message, + SpanId = "span", + TraceId = "trace", + ScopeName = "test-scope", + EventName = eventName, + Resource = new OtlpResource("app", "instance", uninstrumentedPeer: false, context), + ExceptionText = null, + HasGenAI = false + }; + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutHomeTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutHomeTests.cs index 4827f16056a..08c12e98a07 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutHomeTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutHomeTests.cs @@ -25,7 +25,8 @@ namespace Aspire.Dashboard.Components.Tests.Layout; public class ResourcesLayoutHomeTests : DashboardTestContext { // A tab's content is a page scoped by the resource list beside it, not a pane over the table, so no page has a - // close button. The table is the home page, reached from the application name or by expanding the list. + // close button. The table is the home page, reached from the application name or by expanding the list. Escape, + // which used to press the close button, still leads there from every page (see ResourcesLayout.razor.js). [Theory] [InlineData("/resources")] [InlineData("/resources/api")] @@ -38,7 +39,7 @@ public class ResourcesLayoutHomeTests : DashboardTestContext [InlineData("/traces?type=server")] [InlineData("/traces/detail/missing?resource=api&type=server&filters=example&spanId=span")] [InlineData("/metrics/resource/api/meter/Test/instrument/Requests?duration=30")] - public void Pages_HaveNoCloseButtonAndShowTheirContent(string url) + public void Pages_HaveNoCloseButtonAndEscapeGoesHome(string url) { var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true, @@ -51,6 +52,40 @@ public void Pages_HaveNoCloseButtonAndShowTheirContent(string url) Assert.Empty(cut.FindAll(".resource-header-controls .detail-page-close")); Assert.False(cut.Find(".resources-layout").ClassList.Contains("pane-full-canvas")); Assert.False(cut.Find(".resource-content").HasAttribute("inert")); + Assert.Equal("true", cut.Find(".resources-layout").GetAttribute("data-escape-home")); + } + + // Escape goes home only where there's a table to go to and it isn't already showing or loading. On a narrow layout + // "/" redirects to the overview, and without the resource service it redirects to the structured logs (see Home). + [Theory] + [InlineData("home")] + [InlineData("narrow")] + [InlineData("no-resource-service")] + [InlineData("home-loading")] + public async Task EscapeHome_IsOffWhereItWouldNotReachTheTable(string scenario) + { + var viewport = new ViewportInformation(IsDesktop: scenario != "narrow", IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, scenario == "no-resource-service" + ? new TestDashboardClient(isEnabled: false) + : new TestDashboardClient(isEnabled: true, + initialResources: [ModelTestHelpers.CreateResource("api")], + resourceChannelProvider: Channel.CreateUnbounded>)); + Services.GetRequiredService().NavigateTo(scenario switch + { + "home" => "/", + "no-resource-service" => "/structuredlogs", + _ => "/resources/api" + }); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + if (scenario == "home-loading") + { + Assert.Equal("true", cut.Find(".resources-layout").GetAttribute("data-escape-home")); + // What the brand does before it navigates home (see MainLayout.GoToAllResourcesAsync). + await cut.InvokeAsync(cut.Instance.OnHomeNavigationStarting); + } + + Assert.Null(cut.Find(".resources-layout").GetAttribute("data-escape-home")); } [Theory] diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs index b9347f1d911..c1f3459672b 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Threading.Channels; using Aspire.Dashboard.Components.Layout; using Aspire.Dashboard.Components.Resize; @@ -1469,6 +1470,49 @@ Task SelectTabAsync(string id) => cut.InvokeAsync(() => cut.FindComponent>)); + var icon = Services.GetRequiredService().ResolveHighlightedCommandIcon(command.IconName, command.IconVariant); + var preview = Render>(builder => builder.Add(component => component.Value, icon)); + var originalStyle = preview.Find("svg").GetAttribute("style"); + + Services.GetRequiredService().NavigateTo(DashboardUrls.ConsoleLogsUrl("api")); + var cut = Render(builder => builder.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".resource-row-highlighted"))); + preview.Render(); + Assert.Equal(originalStyle, preview.Find("svg").GetAttribute("style")); + + using var gridActions = Render(builder => builder + .AddCascadingValue(viewport) + .Add(component => component.Resource, resource) + .Add(component => component.ResourceByName, new ConcurrentDictionary(StringComparers.ResourceName) { [resource.Name] = resource }) + .Add(component => component.MaxHighlightedCount, 1) + .Add(component => component.CommandSelected, EventCallback.Empty) + .Add(component => component.OnViewDetails, EventCallback.Empty) + .Add(component => component.IsCommandExecuting, (_, _) => false)); + preview.Render(); + Assert.Equal(originalStyle, preview.Find("svg").GetAttribute("style")); + + using var overview = Render(builder => builder.AddChildContent( + cut.Instance.RenderResourceActions(resource, showLabels: true, row: null))); + preview.Render(); + Assert.Equal(originalStyle, preview.Find("svg").GetAttribute("style")); + + cut.Render(); + preview.Render(); + Assert.Equal(originalStyle, preview.Find("svg").GetAttribute("style")); + } + [Fact] public async Task RowActions_RunHighlightedCommandsAndOfferTheResourceMenu() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs index 70a8c738bfb..a657e2ee08f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs @@ -417,7 +417,7 @@ private void SetupStructureLogsServices() FluentUISetupHelpers.SetupFluentToolbar(this); FluentUISetupHelpers.SetupFluentAnchoredRegion(this); - JSInterop.SetupVoid("focusElement", _ => true); + JSInterop.SetupVoid("focusElement", _ => true).SetVoidResult(); FluentUISetupHelpers.AddCommonDashboardServices(this); Services.AddSingleton>(NullLogger.Instance); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs index a47372d2bdc..512b5ddbb90 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs @@ -10,7 +10,6 @@ using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; -using Aspire.Dashboard.Utils; using Bunit; using Google.Protobuf.Collections; using Microsoft.AspNetCore.InternalTesting; @@ -39,10 +38,8 @@ public TraceDetailsTests(ITestOutputHelper testOutputHelper) _testOutputHelper = testOutputHelper; } - [Theory] - [InlineData("Test event", "Test event")] - [InlineData(null, "Log entry details")] - public async Task Render_ManySpanLogs_RendersHiddenTooltipContent(string? eventName, string expectedEventName) + [Fact] + public async Task Render_ManySpanLogs_ShareOneHiddenPreview() { SetupTraceDetailsServices(); var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); @@ -66,7 +63,7 @@ await repository.AddTracesAsync(new AddContext(), var logs = new ScopeLogs { Scope = CreateScope(name: "test-scope") }; logs.LogRecords.Add(Enumerable.Range(0, 501).Select(index => CreateLogRecord(time: s_testTime.AddMilliseconds(index), traceId: "1", spanId: "1-1", - eventName: eventName, message: "Test \nwith details"))); + eventName: "Test event", message: "Test \nwith details"))); await repository.AddLogsAsync(new AddContext(), [ new ResourceLogs { Resource = CreateResource(), ScopeLogs = { logs } } @@ -76,34 +73,31 @@ await repository.AddLogsAsync(new AddContext(), .Add(p => p.TraceId, Convert.ToHexString(Encoding.UTF8.GetBytes("1"))) .AddCascadingValue(viewport)); - cut.WaitForAssertion(() => + // A span can have thousands of logs, so the markers share one hidden preview rather than rendering one each. + cut.WaitForAssertion(() => Assert.Equal(501, cut.FindAll(".span-log-entry-button").Count)); + Assert.Single(cut.FindAll(".log-tooltip")); + var preview = cut.Find($"#{SpanLogPreview.ElementId}"); + Assert.True(preview.HasAttribute("hidden")); + Assert.Empty(preview.Children); + Assert.All(cut.FindAll(".span-log-entry-button"), button => { - var buttons = cut.FindAll(".span-log-entry-button"); - Assert.Equal(501, buttons.Count); - var tooltipSources = cut.FindAll(".log-tooltip-table").Select(table => table.ParentElement!).ToDictionary(source => source.Id!); - Assert.Equal(501, tooltipSources.Count); - var timeProvider = Services.GetRequiredService(); - Assert.All(buttons, (button, index) => - { - var source = tooltipSources[button.GetAttribute("data-tooltip-content")!]; - Assert.Equal($"{button.Id}-tooltip", source.Id); - Assert.True(source.HasAttribute("hidden")); - Assert.Equal(["DIV", "TABLE"], source.Children.Select(child => child.TagName)); - Assert.Equal(expectedEventName, source.QuerySelector(".log-tooltip-title")!.TextContent); - Assert.Equal("test-scope", source.QuerySelector(".log-tooltip-subtitle")!.TextContent); - Assert.Equal(["SPAN", "SPAN", "BR"], source.QuerySelector(".log-tooltip-title-container")!.Children.Select(child => child.TagName)); - Assert.Collection(source.QuerySelectorAll("tr"), - row => Assert.Equal(["Level", "Information"], row.QuerySelectorAll("td").Select(cell => cell.TextContent)), - row => Assert.Equal(["Timestamp", FormatHelpers.FormatTimeWithOptionalDate(timeProvider, s_testTime.AddMilliseconds(index), MillisecondsDisplay.Truncated)], row.QuerySelectorAll("td").Select(cell => cell.TextContent)), - row => Assert.Equal(["Message", "Test \nwith details"], row.QuerySelectorAll("td").Select(cell => cell.TextContent))); - Assert.Equal("Log entry details", button.GetAttribute("aria-label")); - }); - Assert.Equal( - cut.Find(".resource-name").TextContent.Trim(), - cut.Find("[data-tooltip-cell]").GetAttribute("data-tooltip-cell")); - // Only the bounded trace header keeps its rich Fluent tooltip. - Assert.Single(cut.FindComponents()); + Assert.Equal(SpanLogPreview.ElementId, button.GetAttribute("data-tooltip-content")); + Assert.Equal("Log entry details", button.GetAttribute("aria-label")); }); + + // Reaching a marker, with the pointer or the keyboard, fills the preview with its entry (see + // SpanLogPreviewTests): every marker handles both. + Assert.All(cut.FindAll(".span-log-entry-button"), button => + { + Assert.True(button.HasAttribute("blazor:onmouseenter")); + Assert.True(button.HasAttribute("blazor:onfocus")); + }); + + Assert.Equal( + cut.Find(".resource-name").TextContent.Trim(), + cut.Find("[data-tooltip-cell]").GetAttribute("data-tooltip-cell")); + // Only the bounded trace header keeps its rich Fluent tooltip. + Assert.Single(cut.FindComponents()); } [Fact] @@ -1180,7 +1174,7 @@ private void SetupTraceDetailsServices(ILoggerFactory? loggerFactory = null) FluentUISetupHelpers.SetupFluentToolbar(this); FluentUISetupHelpers.SetupFluentMenu(this); - JSInterop.SetupVoid("focusElement", _ => true); + JSInterop.SetupVoid("focusElement", _ => true).SetVoidResult(); loggerFactory ??= NullLoggerFactory.Instance; diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs index bd587f08432..5f8484c417b 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs @@ -329,7 +329,7 @@ private void SetupTracesServices() FluentUISetupHelpers.SetupFluentToolbar(this); FluentUISetupHelpers.SetupFluentAnchoredRegion(this); - JSInterop.SetupVoid("focusElement", _ => true); + JSInterop.SetupVoid("focusElement", _ => true).SetVoidResult(); FluentUISetupHelpers.AddCommonDashboardServices(this); Services.AddSingleton>(NullLogger.Instance); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 1eec90b5c29..cf5366b561a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -52,8 +52,8 @@ public static void SetupFluentMenu(BunitContext context) public static void SetupFluentOverflow(BunitContext context) { var overflowModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Overflow/FluentOverflow.razor.js")); - overflowModule.SetupVoid("fluentOverflowInitialize", _ => true); - overflowModule.SetupVoid("fluentOverflowDispose", _ => true); + overflowModule.SetupVoid("fluentOverflowInitialize", _ => true).SetVoidResult(); + overflowModule.SetupVoid("fluentOverflowDispose", _ => true).SetVoidResult(); } public static void SetupFluentAnchor(BunitContext context) @@ -64,9 +64,9 @@ public static void SetupFluentAnchor(BunitContext context) public static void SetupFluentAnchoredRegion(BunitContext context) { var module = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/AnchoredRegion/FluentAnchoredRegion.razor.js")); - module.SetupVoid("goToNextFocusableElement", _ => true); - module.SetupVoid("initializeKeyboardNavigation", _ => true); - module.SetupVoid("removeKeyboardNavigation", _ => true); + module.SetupVoid("goToNextFocusableElement", _ => true).SetVoidResult(); + module.SetupVoid("initializeKeyboardNavigation", _ => true).SetVoidResult(); + module.SetupVoid("removeKeyboardNavigation", _ => true).SetVoidResult(); } public static void SetupFluentDivider(BunitContext context) @@ -203,7 +203,7 @@ public static void AddCommonDashboardServices( context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Utilities.Attributes.copyToShadow", _ => true); context.JSInterop.SetupVoid("Blazor.theme.setThemeMode", _ => true); var tooltipModule = context.JSInterop.SetupModule("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Tooltip/FluentTooltip.razor.js"); - tooltipModule.SetupVoid("Microsoft.FluentUI.Blazor.Tooltip.FluentTooltipInitialize", _ => true); + tooltipModule.SetupVoid("Microsoft.FluentUI.Blazor.Tooltip.FluentTooltipInitialize", _ => true).SetVoidResult(); context.Services.AddLocalization(); context.Services.AddSingleton(browserTimeProvider ?? new TestTimeProvider()); context.Services.AddSingleton(TimeProvider.System); diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneFullCanvasTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneFullCanvasTests.cs index 11024ff3167..5f2b8e46a8e 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneFullCanvasTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneFullCanvasTests.cs @@ -286,6 +286,68 @@ await RunTestAsync(async page => }, useDefaultDensity: true); } + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task Escape_GoesHomeLikeTheBrandWhenNothingElseUsesIt() + { + await RunTestAsync(async page => + { + await page.SetViewportSizeAsync(1440, 900); + await page.GotoAsync("/resources/tag-a"); + var fullCanvas = page.Locator(".resources-layout.pane-full-canvas"); + var row = page.Locator(".resource-row[data-resource-name='tag-a'] .resource-row-link"); + var overview = page.GetByRole(AriaRole.Tab, new() { Name = Resources.Layout.ResourceTabOverview, Exact = true }); + await Assertions.Expect(page.Locator(".resources-layout")).ToHaveAttributeAsync("data-pane-interactions-ready", "true"); + await Assertions.Expect(page.Locator(".resources-layout")).ToHaveAttributeAsync("data-escape-home", "true"); + + // A text field keeps Escape. + await page.Locator(".resource-pane").GetByRole(AriaRole.Searchbox).FocusAsync(); + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(page).ToHaveURLAsync(new Regex("/resources/tag-a$")); + + // So does an open menu, which closes. + await page.Locator("#resourcePaneGroupingButton").ClickAsync(); + var noGrouping = page.GetByRole(AriaRole.Menuitemradio, new() { Name = "None", Exact = true }); + await Assertions.Expect(noGrouping).ToBeVisibleAsync(); + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(noGrouping).ToBeHiddenAsync(); + // The menu closes in the browser, and its button reports it closed once the server renders it. Until then, + // Escape still counts as used by the menu. + await Assertions.Expect(page.Locator("#resourcePaneGroupingButton")).Not.ToHaveAttributeAsync("aria-expanded", "true"); + await Assertions.Expect(page).ToHaveURLAsync(new Regex("/resources/tag-a$")); + await Assertions.Expect(fullCanvas).ToHaveCountAsync(0); + + // Otherwise it does what the brand does: the table of every resource, at home. + await row.FocusAsync(); + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(page).ToHaveURLAsync(ResourcePaneNavigation.HomeUrl); + await Assertions.Expect(fullCanvas).ToBeVisibleAsync(); + await AssertNoTabSelectedAsync(page); + await Assertions.Expect(page.Locator(".resources-layout")).Not.ToHaveAttributeAsync("data-escape-home", "true"); + + // The data tabs go home too, as their close button did. + await page.GotoAsync("/consolelogs/resource/tag-a"); + await Assertions.Expect(page.Locator(".resources-layout")).ToHaveAttributeAsync("data-escape-home", "true"); + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(page).ToHaveURLAsync(ResourcePaneNavigation.HomeUrl); + await Assertions.Expect(fullCanvas).ToBeVisibleAsync(); + + // A tab with keyboard focus doesn't get it back in the strip re-created at home, where it would show its + // focus ring. + await page.GotoAsync("/resources/tag-a"); + await Assertions.Expect(page.Locator(".resources-layout")).ToHaveAttributeAsync("data-escape-home", "true"); + await overview.FocusAsync(); + await Assertions.Expect(overview).ToBeFocusedAsync(); + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(page).ToHaveURLAsync(ResourcePaneNavigation.HomeUrl); + await Assertions.Expect(fullCanvas).ToBeVisibleAsync(); + // Give the strip's focus restore, which runs as the strip is re-created and again on the next frame, time + // to act before checking it didn't. + await page.WaitForTimeoutAsync(500); + await Assertions.Expect(page.Locator(".resources-tab-header fluent-tab:focus")).ToHaveCountAsync(0); + }); + } + // At home the active tab is a hidden placeholder, so no visible tab is selected. private static Task AssertNoTabSelectedAsync(IPage page) => Assertions.Expect(page.Locator(".resources-tab-header fluent-tab[aria-selected='true']:visible")).ToHaveCountAsync(0); diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs index 5cff1929d95..09adceb612a 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs @@ -36,6 +36,16 @@ namespace Aspire.Dashboard.Tests.Integration.Playwright; public sealed class ResourcePaneResponsiveTests(ResourcePaneResponsiveTests.MetadataResourcesFixture fixture) : PlaywrightTestsBase(fixture) { + // Playwright considers opacity: 0 visible; checkVisibility also accounts for transparent ancestors. + // See https://playwright.dev/dotnet/docs/actionability#visible + private const string IsShown = """ + el => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && + el.checkVisibility({ opacityProperty: true, visibilityProperty: true }); + } + """; + [Theory] [InlineData(false)] [InlineData(true)] @@ -640,6 +650,10 @@ await RunTestAsync(async page => // middle of that makes FluentDataGrid use a disposed JS module and end the circuit. The data tabs have no close // button for it. await page.GotoAsync(fullCanvas ? "/" : "/resources/cache?pane=tags"); + // The feedback prompt is shown above the header once the page has read browser storage, after the first + // render, and pushes the page down. Hovering before it arrives can leave the pointer over the brand, + // whose tooltip then shows instead of the button's. + await Assertions.Expect(page.GetByText(Resources.Dialogs.DashboardFeedbackPromptTitle, new() { Exact = true })).ToBeVisibleAsync(); if (width <= 768) { await page.Locator(".resource-picker").ClickAsync(); @@ -1747,20 +1761,20 @@ await page.WaitForFunctionAsync(""" await Assertions.Expect(pane).ToBeVisibleAsync(); // Compact sidebar rows reveal their actions on hover, so measure them with the pointer away first. await page.Mouse.MoveAsync(1, 1); - const string measureRows = """ + var measureRows = $$""" rows => rows.map(row => { const rect = row.getBoundingClientRect(); const next = row.nextElementSibling; const rail = row.querySelector('.resource-row-actions'); - const shown = el => el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0; + const shown = {{IsShown}}; const overflow = rail.querySelector('.aspire-menu-button'); const essential = [...rail.querySelectorAll('.resource-row-highlighted, .resource-row-primary-endpoint')]; return [rect.height, next?.matches('.resource-row[data-resource-name]') ? next.getBoundingClientRect().top - rect.bottom : 1, - rail.getBoundingClientRect().width, shown(overflow) ? 1 : 0, + shown(rail) ? rail.getBoundingClientRect().width : 0, overflow && shown(overflow) ? 1 : 0, essential.every(shown) ? 1 : 0, [...rail.querySelectorAll('.resource-row-action')].some(shown) ? 1 : 0, - row.matches(':hover, :focus-within') ? 1 : 0, + row.matches(':hover') || row.querySelector(':focus-visible, .resource-row-actions [aria-haspopup][aria-expanded="true"]') ? 1 : 0, rail.querySelectorAll('.resource-row-highlighted').length]; }) """; @@ -1781,6 +1795,8 @@ await page.WaitForFunctionAsync(""" } else { + // Every row has its overflow menu, parameters included: it holds what the row's buttons + // don't, such as Delete. Assert.Equal(1, values[3]); if (!fullCanvas) { @@ -1796,7 +1812,15 @@ await page.WaitForFunctionAsync(""" Assert.True(rows.Count(values => values[6] == 0) >= rows.Length - 1); // Hover reveals the full rail, and so does keyboard focus, so the actions stay reachable // without a pointer. - foreach (var reveal in new Func[] { row => row.HoverAsync(), row => row.Locator(".resource-row-link").FocusAsync() }) + foreach (var reveal in new Func[] + { + row => row.HoverAsync(), + async row => + { + await page.Keyboard.PressAsync("Tab"); + await row.Locator(".resource-row-link").FocusAsync(); + } + }) { foreach (var resourceName in new[] { "cache", "api" }) { @@ -1944,15 +1968,28 @@ await RunTestAsync(async page => await Assertions.Expect(row.Locator(".resource-row-meta-state")).ToHaveAttributeAsync("data-tooltip", "Running (Unhealthy)"); await Assertions.Expect(row.Locator(".resource-row-link")).ToHaveAttributeAsync("aria-description", new System.Text.RegularExpressions.Regex("Unhealthy")); await Assertions.Expect(row.Locator(".resource-row-link .state-dot")).ToHaveCountAsync(1); - await Assertions.Expect(endpoint).Not.ToBeVisibleAsync(); + Assert.False(await endpoint.EvaluateAsync(IsShown)); Assert.True(await row.Locator(".resource-row-text").EvaluateAsync("el => el.clientWidth") >= 160); + + await row.Locator(".resource-row-link").ClickAsync(); + await Assertions.Expect(row.Locator(".resource-row-link")).ToHaveAttributeAsync("aria-current", "page"); + await page.Mouse.MoveAsync(1, 1); + await Assertions.Expect(row.Locator(".resource-row-meta .resource-row-type")).ToBeVisibleAsync(); + Assert.False(await endpoint.EvaluateAsync(IsShown)); + await Assertions.Expect(row.Locator(".resource-row-link")).ToBeFocusedAsync(); + await page.Keyboard.PressAsync("Tab"); + await Assertions.Expect(endpoint).ToBeFocusedAsync(); + Assert.True(await endpoint.EvaluateAsync(IsShown)); + await Assertions.Expect(row.Locator(".resource-row-meta")).Not.ToBeVisibleAsync(); + await endpoint.BlurAsync(); + // Hover and focus swap the type and state columns for the actions, which keeps the name's room. await row.HoverAsync(); await row.Locator(".resource-row-link").FocusAsync(); await Assertions.Expect(row.Locator(".resource-row-meta")).Not.ToBeVisibleAsync(); Assert.True(await row.Locator(".resource-row-text").EvaluateAsync("el => el.clientWidth") >= 160); - await Assertions.Expect(endpoint).ToBeVisibleAsync(); + Assert.True(await endpoint.EvaluateAsync(IsShown)); await Assertions.Expect(endpoint).ToHaveAttributeAsync("href", MetadataResourcesFixture.Endpoint); await Assertions.Expect(endpoint).ToHaveAttributeAsync("target", "_blank"); await Assertions.Expect(endpoint).ToHaveAttributeAsync("rel", "noreferrer noopener"); @@ -1994,10 +2031,10 @@ await RunTestAsync(async page => // Per row: the badge's box; whether it is the topmost element at its center; the hidden spacer's box, // which stops the name before the badge; the end of the name's text; whether the badge overlaps a // shown column or action; and whether any action is shown. - const string measure = """ + var measure = $$""" row => { const box = el => el.getBoundingClientRect(); - const shown = el => box(el).width > 0 && box(el).height > 0 && getComputedStyle(el).visibility !== 'hidden'; + const shown = {{IsShown}}; const badge = row.querySelector(':scope > .resource-row-error-link'); const b = box(badge); const top = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); @@ -2043,6 +2080,7 @@ async Task AssertRowAsync(string name, string stage, double typeWidth) AssertBadge(rest, await row.EvaluateAsync(measure), context, "badge hover", actionsShown: true); await page.Mouse.MoveAsync(1, 1); + await page.Keyboard.PressAsync("Tab"); await row.Locator(".resource-row-link").FocusAsync(); AssertBadge(rest, await row.EvaluateAsync(measure), context, "focus", actionsShown: true); @@ -2182,9 +2220,10 @@ await RunTestAsync(async page => // element at its center; whether the icon's glyph is hidden under it; whether the shown actions, in DOM // (tab) order, run left to right without overlapping; how many are shown; and whether the name ends before // the first of them. - const string measure = """ + var measure = $$""" row => { const box = el => el.getBoundingClientRect(); + const isShown = {{IsShown}}; const toggle = row.querySelector(':scope > .resource-row-toggle-leading'); const t = box(toggle), icon = box(row.querySelector('.resource-row-icon')); const shown = getComputedStyle(toggle).visibility === 'visible'; @@ -2193,7 +2232,7 @@ await RunTestAsync(async page => const top = document.elementFromPoint(t.left + t.width / 2, t.top + t.height / 2); const glyphHidden = getComputedStyle(row.querySelector('.resource-row-glyph')).visibility === 'hidden'; const controls = [...row.querySelectorAll('.resource-row-actions .resource-row-action')] - .filter(el => box(el).width > 0 && box(el).height > 0); + .filter(isShown); const ordered = controls.every((el, i) => i === 0 || box(controls[i - 1]).right <= box(el).left + 0.5); const clear = controls.length === 0 || box(row.querySelector('.resource-row-text')).right <= box(controls[0]).left + 0.5; return [shown ? 1 : 0, covers ? 1 : 0, top?.closest('.resource-row-toggle') === toggle ? 1 : 0, @@ -2216,6 +2255,7 @@ await RunTestAsync(async page => AssertToggle(await row.EvaluateAsync(measure), name, "row hover", shownControls, checkTopmost: true); await page.Mouse.MoveAsync(1, 1); + await page.Keyboard.PressAsync("Tab"); await row.Locator(".resource-row-link").FocusAsync(); AssertToggle(await row.EvaluateAsync(measure), name, "focus", shownControls, checkTopmost: true); diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/TemplateResourceAssertionsTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/TemplateResourceAssertionsTests.cs index 6781bc11957..11bc76129b2 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/TemplateResourceAssertionsTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/TemplateResourceAssertionsTests.cs @@ -23,11 +23,15 @@ public sealed class TemplateResourceAssertionsTests(TemplateResourceAssertionsTe new("Container", "cache", "Running", "registry.example.test/redis:latest") ]; + // Templates and other app tests open the dashboard in its default density, compact, whose rows show the state + // as a dot, so the helper must read the state at every density. [Theory] - [InlineData("/resources")] - [InlineData("/graph")] + [InlineData("/resources", true)] + [InlineData("/resources", false)] + [InlineData("/graph", true)] + [InlineData("/graph", false)] [OuterloopTest("Resource-intensive Playwright browser test")] - public async Task TemplateHelper_ValidatesSidebarAndDetailsWithoutCards(string path) + public async Task TemplateHelper_ValidatesSidebarAndDetailsWithoutCards(string path, bool useDefaultDensity) { await RunTestAsync(async page => { @@ -35,11 +39,18 @@ await RunTestAsync(async page => await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(2); var originalUrl = page.Url; + foreach (var resource in s_expectedResources) + { + var row = page.Locator($".resource-row[data-resource-name='{resource.Name}']"); + await Assertions.Expect(DashboardResourceAssertions.RowState(row, resource.State)).ToBeVisibleAsync(); + await Assertions.Expect(DashboardResourceAssertions.RowState(row, "Finished")).ToHaveCountAsync(0); + } + var rows = await DashboardResourceAssertions.CheckResourcesAsync(new WrapperForIPage(page, output), s_expectedResources, output, timeoutSecs: 10); Assert.Equal(s_expectedResources.OrderBy(resource => resource.Name), rows.OrderBy(resource => resource.Name)); Assert.Equal(originalUrl, page.Url); - }); + }, useDefaultDensity); } [Theory] diff --git a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs index 99d2ca3cf87..11411ed06e8 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs @@ -25,7 +25,6 @@ internal sealed class TerminalTestHost : ITerminalConnectionResolver, IAsyncDisp private readonly ConcurrentBag _attachmentDisposals = []; private int _disposedAttachments; private int _terminalEnded; - private int _includeHmpExit; private readonly TaskCompletionSource _endedObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); public TerminalTestHost(ITestOutputHelper output, bool requireAuthentication, bool useGrpc = false) @@ -49,6 +48,11 @@ public TerminalTestHost(ITestOutputHelper output, bool requireAuthentication, bo public Hex1bAppWorkloadAdapter Workload => _producer.Workload; public Hmp1PresentationAdapter Presentation => _producer.Presentation; + public TimeSpan ProducerOutputDelay + { + get => _producer.OutputDelay; + set => _producer.OutputDelay = value; + } public int ConnectionCount => _producer.ConnectionCount; public int DisposedAttachments => Volatile.Read(ref _disposedAttachments); public StatusCode? AttachmentFailureStatus { get; init; } @@ -62,10 +66,14 @@ public TerminalViewSession CreateViewSession(bool readOnly) => public Task WaitForEndedObservedAsync(CancellationToken cancellationToken) => _endedObserved.Task.WaitAsync(cancellationToken); - public async Task EndTerminalAsync(bool includeHmpExit) + public async Task EndTerminalAsync(bool includeHmpExit, CancellationToken cancellationToken = default) { - Volatile.Write(ref _includeHmpExit, includeHmpExit ? 1 : 0); Volatile.Write(ref _terminalEnded, 1); + if (includeHmpExit) + { + // End like a producer whose workload exited: HMP Exit follows every queued output frame. + await _producer.CompleteAsync(cancellationToken); + } await Presentation.DisposeAsync(); } @@ -137,8 +145,7 @@ private async Task AttachTerminalAsync(string terminalId, CancellationTo _attachmentDisposals.Add(disposed.Task); var call = new AsyncDuplexStreamingCall( new TerminalRequestWriter(connection), - new TerminalResponseReader(connection, () => Volatile.Read(ref _terminalEnded) != 0, - () => Volatile.Read(ref _includeHmpExit) != 0, _endedObserved, failure), + new TerminalResponseReader(connection, () => Volatile.Read(ref _terminalEnded) != 0, _endedObserved, failure), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), @@ -166,14 +173,13 @@ public async ValueTask DisposeAsync() } } - private sealed class TerminalResponseReader(Stream stream, Func terminalEnded, Func includeHmpExit, + private sealed class TerminalResponseReader(Stream stream, Func terminalEnded, TaskCompletionSource endedObserved, RpcException? failure) : IAsyncStreamReader { // Deliberately split HMP frames across small gRPC messages: transport boundaries // must not affect the HMP handshake, UTF-8 input, graphics, or terminal state. private readonly byte[] _buffer = new byte[31]; private bool _sentEnded; - private bool _sentExit; public TerminalServerFrame Current { get; private set; } = new(); @@ -188,17 +194,6 @@ public async Task MoveNext(CancellationToken cancellationToken) var count = await stream.ReadAsync(_buffer, cancellationToken); if (count == 0) { - if (terminalEnded() && includeHmpExit() && !_sentExit) - { - // HMP Exit precedes gRPC Ended: type 0x06, four-byte LE payload - // length, then a four-byte LE exit code (zero in this fixture). - // https://github.com/mitchdenny/hex1b/blob/798b26c/docs/muxer-protocol.md#exit-0x06 - _sentExit = true; - byte[] exit = [0x06, 4, 0, 0, 0, 0, 0, 0, 0]; - Current = new TerminalServerFrame { Data = ByteString.CopyFrom(exit) }; - return true; - } - if (terminalEnded() && !_sentEnded) { _sentEnded = true; diff --git a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestProducer.cs b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestProducer.cs index 066d9c18a4e..e338de7b18e 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestProducer.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestProducer.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Binary; using System.Collections.Concurrent; using System.IO.Pipelines; using Hex1b; @@ -12,10 +13,12 @@ namespace Aspire.Dashboard.Tests.Shared; internal sealed class TerminalTestProducer : IAsyncDisposable { private readonly ConcurrentBag> _connections = []; + private readonly ConcurrentBag _outputs = []; private readonly ConcurrentBag _disconnections = []; private readonly CancellationTokenSource _stopping = new(); private readonly Hex1bTerminal _terminal; private int _disposed; + private long _outputDelayTicks; public TerminalTestProducer(int width, int height, int scrollback) { @@ -36,13 +39,25 @@ public TerminalTestProducer(int width, int height, int scrollback) public int Height => Workload.Height; public int ConnectionCount => _connections.Count; + /// + /// Delays each later producer write to viewer transports, widening the window in which + /// the producer has applied output that its HMP write pumps have not yet delivered. + /// + public TimeSpan OutputDelay + { + get => TimeSpan.FromTicks(Interlocked.Read(ref _outputDelayTicks)); + set => Interlocked.Exchange(ref _outputDelayTicks, value.Ticks); + } + public Stream Connect() { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); var toClient = new Pipe(); var toServer = new Pipe(); var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var server = new DuplexStream(toServer.Reader.AsStream(), toClient.Writer.AsStream(), onDispose: null); + var output = new ProducerOutputStream(toClient.Writer.AsStream(), () => OutputDelay); + _outputs.Add(output); + var server = new DuplexStream(toServer.Reader.AsStream(), output, onDispose: null); var client = new DuplexStream(toClient.Reader.AsStream(), toServer.Writer.AsStream(), () => disconnected.TrySetResult()); _disconnections.Add(disconnected.Task); _connections.Add(Presentation.AddClient(server, _stopping.Token)); @@ -60,6 +75,17 @@ public async Task WaitForProducerTextAsync(string text, CancellationToken cancel public Task WaitForPeerHandshakesAsync(CancellationToken cancellationToken) => Task.WhenAll(_connections).WaitAsync(cancellationToken); + /// + /// Completes the producer terminal and waits until every viewer transport has received + /// HMP Exit. Hex1b queues Exit behind all output already queued for each viewer, whereas + /// disposing the presentation discards undelivered output. + /// + public async Task CompleteAsync(CancellationToken cancellationToken) + { + Presentation.TerminalCompleted(exitCode: 0); + await Task.WhenAll(_outputs.Select(output => output.ExitWrittenOrClosed)).WaitAsync(cancellationToken); + } + public Task WaitForAttachmentsReleasedAsync(CancellationToken cancellationToken) => Task.WhenAll(_disconnections).WaitAsync(cancellationToken); @@ -123,4 +149,87 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } + + private sealed class ProducerOutputStream(Stream inner, Func delay) : Stream + { + private const byte ExitFrameType = 0x06; + private const int FrameHeaderLength = 5; + private readonly TaskCompletionSource _exitWrittenOrClosed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly byte[] _header = new byte[FrameHeaderLength]; + private int _headerLength; + private long _payloadRemaining; + private bool _inExitFrame; + + // HMP frames are a one-byte type, a four-byte little-endian payload length, then the payload. + // https://github.com/mitchdenny/hex1b/blob/798b26c/docs/muxer-protocol.md + public Task ExitWrittenOrClosed => _exitWrittenOrClosed.Task; + + public override bool CanRead => false; + public override bool CanWrite => inner.CanWrite; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (delay() is { Ticks: > 0 } wait) + { + await Task.Delay(wait, cancellationToken); + } + await inner.WriteAsync(buffer, cancellationToken); + Observe(buffer.Span); + } + + public override void Flush() => inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => inner.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _exitWrittenOrClosed.TrySetResult(); + inner.Dispose(); + } + base.Dispose(disposing); + } + + private void Observe(ReadOnlySpan data) + { + while (!data.IsEmpty) + { + if (_payloadRemaining == 0) + { + var copied = Math.Min(FrameHeaderLength - _headerLength, data.Length); + data[..copied].CopyTo(_header.AsSpan(_headerLength)); + _headerLength += copied; + data = data[copied..]; + if (_headerLength < FrameHeaderLength) + { + return; + } + + _headerLength = 0; + _inExitFrame = _header[0] == ExitFrameType; + _payloadRemaining = BinaryPrimitives.ReadUInt32LittleEndian(_header.AsSpan(1)); + } + else + { + var skipped = (int)Math.Min(_payloadRemaining, data.Length); + _payloadRemaining -= skipped; + data = data[skipped..]; + } + + if (_payloadRemaining == 0 && _inExitFrame) + { + _exitWrittenOrClosed.TrySetResult(); + } + } + } + } } diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs index a07336a1928..bfc2123bac4 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -707,7 +707,7 @@ public async Task BrowserView_AppHostEndClosesWithCompletionStatusAndReleasesMir while (!frame.EndOfMessage); } - await host.EndTerminalAsync(includeHmpExit); + await host.EndTerminalAsync(includeHmpExit, timeout.Token); await host.WaitForEndedObservedAsync(timeout.Token); await session.Ended.WaitAsync(timeout.Token); var close = await ReadCloseAsync(browser, timeout.Token); @@ -719,9 +719,11 @@ public async Task BrowserView_AppHostEndClosesWithCompletionStatusAndReleasesMir } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task BrowserView_DrainsFinalOutputAndGraphicsBeforeDisconnect(bool useGrpc) + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task BrowserView_DrainsFinalOutputAndGraphicsBeforeDisconnect(bool useGrpc, bool slowProducerDelivery) { await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); @@ -729,11 +731,17 @@ public async Task BrowserView_DrainsFinalOutputAndGraphicsBeforeDisconnect(bool using var browser = await host.ConnectBrowserAsync(timeout.Token); // Hold the initial acknowledgement so final output must coalesce behind it. var initial = await ReadFrameAsync(browser, timeout.Token); + if (slowProducerDelivery) + { + // The producer applies output before its HMP write pump delivers it, and disposing + // the producer discards undelivered output. Widen that window deterministically. + host.ProducerOutputDelay = TimeSpan.FromMilliseconds(500); + } host.Workload.Write(string.Join("\r\n", Enumerable.Range(0, 35).Select(i => $"Retained output {i:D2}")) + "\r\nfinal line\r\ncompleted\u001b]2;Final screen\u0007" + "\u001b_Ga=T,f=32,t=d,s=1,v=1,i=7300,q=2;/wAA/w==\u001b\\"); await host.WaitForProducerTextAsync("completed", timeout.Token); - await host.EndTerminalAsync(includeHmpExit: true); + await host.EndTerminalAsync(includeHmpExit: true, timeout.Token); await SendAsync(browser, $$"""{"type":"ack","revision":{{initial.GetProperty("revision").GetUInt32()}}}""", timeout.Token); var final = await ReadUntilAsync(browser, frame => frame.GetProperty("title").GetString() == "Final screen", timeout.Token); @@ -778,6 +786,29 @@ public async Task BrowserView_ProducerDisconnectClosesBrowserWhileWaitingForAckn Assert.Equal(useGrpc ? 1 : 0, host.DisposedAttachments); } + [Fact] + public async Task BrowserView_DelayedCloseReplyCompletesCloseHandshake() + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var session = host.CreateViewSession(readOnly: false); + using var browser = await host.ConnectBrowserAsync(session, timeout.Token); + await ReadUntilAsync(browser, _ => true, timeout.Token); + + await host.Presentation.DisposeAsync(); + var closed = await ReadCloseAsync(browser, timeout.Token); + Assert.Equal(WebSocketCloseStatus.EndpointUnavailable, closed.CloseStatus); + + // Model a browser (or a loaded test machine) whose Close reply arrives after the + // 2 second drain/send grace but within the close handshake timeout. The dashboard + // must not reset the connection under the in-flight reply. + await Task.Delay(TimeSpan.FromSeconds(3), timeout.Token); + await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token); + Assert.Equal(WebSocketState.Closed, browser.State); + await host.WaitForAttachmentsReleasedAsync(timeout.Token); + } + private static async Task ReadFirstLogicalLineAsync(WebSocket socket, int requestId, CancellationToken cancellationToken) { // Scroll to retained history, rather than only testing the freshly replayed live screen. diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs index cddc88b500e..9d2d766ba75 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs @@ -127,7 +127,7 @@ await app.ResourceNotifications.PublishUpdateAsync( var resourceRow = page.Locator($".resource-row[data-resource-name='{resourceName}']"); await Assertions.Expect(resourceRow).ToBeVisibleAsync(); - await Assertions.Expect(resourceRow.GetByText(initialState, new() { Exact = true })).ToBeVisibleAsync(); + await Assertions.Expect(DashboardResourceAssertions.RowState(resourceRow, initialState)).ToBeVisibleAsync(); var updatedResourceTask = app.ResourceNotifications.WaitForResourceAsync( resourceName, @@ -141,7 +141,7 @@ await app.ResourceNotifications.PublishUpdateAsync( // PublishUpdateAsync updates the application notification stream while the AppHost breakpoint remains held. // Dashboard consumption and browser rendering are asynchronous, so wait separately for the rendered change. - await Assertions.Expect(resourceRow.GetByText(updatedState, new() { Exact = true })).ToBeVisibleAsync(); + await Assertions.Expect(DashboardResourceAssertions.RowState(resourceRow, updatedState)).ToBeVisibleAsync(); } finally { diff --git a/tests/Shared/Playwright/DashboardResourceAssertions.cs b/tests/Shared/Playwright/DashboardResourceAssertions.cs index 6ac7de45fb4..0a3b98f5142 100644 --- a/tests/Shared/Playwright/DashboardResourceAssertions.cs +++ b/tests/Shared/Playwright/DashboardResourceAssertions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.RegularExpressions; using Microsoft.Playwright; using Xunit; @@ -50,7 +51,11 @@ public static async Task CheckResourcesAsync( continue; } - var state = (await row.Locator(".resource-row-sub > .state-dot + span").InnerTextAsync()).Trim(); + var state = await GetRowStateAsync(row); + if (state is null) + { + continue; + } if (expected.State != state && state != "Finished" && !state.Contains("failed", StringComparison.OrdinalIgnoreCase)) { continue; @@ -85,6 +90,36 @@ public static async Task CheckResourcesAsync( return foundRows.Values.ToArray(); } + + // Compact rows, the dashboard's default, show the state as a dot whose name is in the tooltip of its column. + // Comfortable and spacious rows show the state's name as text after the dot. + private const string CompactStateSelector = ".resource-row-meta-state"; + private const string StateTextSelector = ".resource-row-sub > .state-dot + span"; + + /// + /// Gets the state a resource row shows, at any density, or if the row hasn't rendered one yet. + /// + public static Task GetRowStateAsync(ILocator row) => + row.EvaluateAsync( + """ + (row, selectors) => { + const compact = row.querySelector(selectors.compact); + if (compact) { + return compact.getAttribute('data-tooltip')?.trim() ?? null; + } + return row.querySelector(selectors.text)?.textContent?.trim() ?? null; + } + """, + new { compact = CompactStateSelector, text = StateTextSelector }); + + /// + /// Locates the element that shows in a resource row, at any density. + /// + public static ILocator RowState(ILocator row, string state) => + row.Locator($"{CompactStateSelector}[data-tooltip=\"{EscapeCssString(state)}\"]") + .Or(row.Locator(StateTextSelector).Filter(new() { HasTextRegex = new Regex($"^{Regex.Escape(state)}$") })); + + private static string EscapeCssString(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); } public sealed record ResourceRow(string Type, string Name, string State, string SourceContains);