From b2e5591b5c8017f582b5e5a1d8c7acdeda309e53 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 10 Nov 2026 09:27:10 -0800 Subject: [PATCH 01/12] Fix flaky terminal WebSocket close handshake abort (ProducerDisconnect test) (#21500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes an intermittent Windows failure in `TerminalWebSocketTests.BrowserView_ProducerDisconnectClosesBrowserWhileWaitingForAcknowledgement(useGrpc: True)`. The test fails when the browser's `CloseOutputAsync` reply throws `WebSocketException: The remote party closed the WebSocket connection without completing the close handshake` (inner error: `SocketException: An existing connection was forcibly closed`). No issue tracks this. It is a different test and a different root cause from #21471, which fixes the producer-side final-output drain in `BrowserView_DrainsFinalOutputAndGraphicsBeforeDisconnect`. ### Evidence - Failure: [main push run 41074175594, Dashboard (windows-latest) job 124127031025](https://github.com/microsoft/aspire/actions/runs/41074175594/job/124127031025), sha 82d064c1. - At 2.689s the server logged `Timed out draining the final terminal frame` (expected, because this test never acks). - At 5.305s it logged `Terminal close handshake failed` with `TaskCanceledException` at the close-reply wait. - The client's stack shows it called `CloseOutputAsync` as soon as `ReadCloseAsync` returned, so the browser received the Close after the server had already aborted. - Denominators (`ci.yml` attempt 1, Oct 16–30, from a partial survey of the window): the test failed in 1 of 56 completed Aspire.Dashboard.Tests Windows jobs and 0 of 56 Ubuntu jobs. - The test code and `TerminalWebSocketProxy` are unchanged between 82d064c1 and current main, and the test passes on later main runs. I also queued a rerun of the failed job on the same head. ### Root cause `TerminalWebSocketProxy.CloseAsync` sends Close and then waits for the browser's Close reply. It uses the same 2-second `s_closeTimeout` that bounds frame drain and sender teardown, and calls `socket.Abort()` when that expires. In this test the browser is an in-process `ClientWebSocket` sharing the test host's thread pool. On a loaded Windows runner its reply can arrive more than 2 seconds after the server sends Close; the CI log shows about 0.6s of scheduling delay even before `CloseAsync` started. The server then resets the connection under the browser's in-flight reply. Waiting for the reply only delays resource cleanup; the browser already has the authoritative close status. So the short drain budget is the wrong bound for this step. **Deterministic repro on main (Linux):** delay the browser's Close reply by 3 seconds after `ReadCloseAsync`. It fails with the identical exception: ``` failed ...TerminalWebSocketTests.BrowserView_DelayedCloseReplyCompletesCloseHandshake (5s 869ms) WebSocketException : The remote party closed the WebSocket connection without completing the close handshake. ---- IOException : Unable to write data to the transport connection: Connection reset by peer. ``` ### Fix - Add a dedicated `s_closeHandshakeTimeout` (10s) for the Close reply wait in `CloseAsync`. - The 2-second `s_closeTimeout` still bounds the final-frame drain and sender teardown, so user-visible close latency is unchanged. - An unresponsive or disconnected browser is still aborted, and a socket error ends the wait immediately. ### Validation - New regression test `BrowserView_DelayedCloseReplyCompletesCloseHandshake`: fails before the fix (above) and passes after. - `Aspire.Dashboard.Tests.Terminal` namespace: 113/113 passed. - Full `Aspire.Dashboard.Tests`: 2583/2583 passed. ## Checklist - Is this feature complete? - [x] Yes. Ready to ship. - [ ] No. Follow-up changes expected. - Are you including unit tests for the changes and scenario tests if relevant? - [x] Yes - [ ] No - Did you add public API? - [ ] Yes - [x] No - Does the change make any security assumptions or guarantees? - [ ] Yes - [x] No --- **🤖 Posted by AspireAgent (AI Agent)** Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 9a91c4f11da4be1f87dd6f7d7cd39a3631aef9ca) --- .../Terminal/TerminalWebSocketProxy.cs | 6 ++++- .../Terminal/TerminalWebSocketTests.cs | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs index a07336a1928..090ebcd2e55 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -778,6 +778,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. From da39af01a6f4eaa7a3db28ba543343fa7589faf6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 10 Nov 2026 08:21:13 -0800 Subject: [PATCH 02/12] Fix bUnit JS timeout flakes in StructuredLogs/Traces/TraceDetails tests (#21503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes an intermittent Dashboard.Components flake: `EmptyState_HelpDisappearsAfterAnySignalAndStaysHiddenAfterClear` (StructuredLogs/Traces), plus TraceDetails tests, fail with `Bunit.JSRuntimeInvocationNotSetException` on slow runners (mostly Windows). ### Root cause In bUnit 2.x, a planned JS handler without a result leaves the invocation pending. After `BunitContext.DefaultWaitTimeout` (10s, set in `DashboardPageTestContext`), bUnit faults the invocation. That fault surfaces from the component's `OnAfterRenderAsync` and is rethrown by the next `WaitForAssertion`. So any test still running 10s after the first render fails. Two kinds of handlers were left without a result: - **`focusElement`**: `StructuredLogsTests`, `TracesTests` and `TraceDetailsTests` each call `JSInterop.SetupVoid("focusElement", _ => true)` without a result. They do this after `SetupFluentMenu` registered a resolved handler. The last registration wins, so the resolved handler is overridden. Each page awaits `focusElement(, true)` on first render. - **Tooltip**: `AddCommonDashboardServices` registers the Tooltip module's `FluentTooltipInitialize` without a result. This is the same bug class #21412 fixed for `Select.Initialize` and `observeAttributeChange`, and #21434 fixes in `ManageDataDialogTests`. This PR doesn't touch those files. ### Fix Add `.SetVoidResult()` to the four handlers above. Also complete the other shared void stubs these pages exercise: the three handlers in `SetupFluentAnchoredRegion` (`goToNextFocusableElement`, `initializeKeyboardNavigation`, `removeKeyboardNavigation`) and the two in `SetupFluentOverflow` (`fluentOverflowInitialize`, `fluentOverflowDispose`). Test-only change. ### CI evidence (attempt-1 failures, `ci.yml`, about Oct 16–31) Survey: 1000 `ci.yml` runs (466 with Dashboard jobs). Dashboard.Components had 10 attempt-1 failures in 181 Windows jobs and 1 in 181 Ubuntu jobs. These 3 jobs failed on pending calls this PR fixes: | Run / job | Head | Failing tests | Pending invocation | |---|---|---|---| | main push [40534267321](https://github.com/microsoft/aspire/actions/runs/40534267321/job/122317031694) (Windows) | b1b850f0 | StructuredLogsTests.EmptyState… (and MetricsTests.EmptyState… on `Select.Initialize`, fixed since by #21412) | `focusElement("structuredLogsScrollContainer", true)` | | PR [40563038552](https://github.com/microsoft/aspire/actions/runs/40563038552/job/122398561156) (Windows) and [job 122398564683](https://github.com/microsoft/aspire/actions/runs/40563038552/job/122398564683) (Ubuntu) | f1cc41e7 | TraceDetailsTests.ExpandAllSpans…, Render_TraceUpdateWithNewSpans…, ToggleCollapse… | `focusElement("traceDetailScrollContainer", true)`, `FluentTooltipInitialize` (plus handlers since fixed on main) | A fourth job, PR run [40390542875](https://github.com/microsoft/aspire/actions/runs/40390542875/job/121814090794) (StructuredLogsTests.EmptyState… on Windows), faulted on `observeAttributeChange`, which #21412 has since fixed. On the same head the Ubuntu job passed. Locally, the 9 EmptyState cases passed 15/15 loops on main because they finish well under 10s. The other 6 Components failures were the MetricsTests histogram test (tracked separately in #21394). ### Before/after **Slow-runner simulation (not committed).** Added `await Task.Delay(11s)` before the final `WaitForAssertion` in the StructuredLogs/Traces EmptyState tests: | | EmptyState (9 cases) | Failure | |---|---|---| | main | **6 failed** / 9 | `JSRuntimeInvocationNotSetException`: `focusElement("structuredLogsScrollContainer"/"tracesScrollContainer", true)` ×5, `FluentTooltipInitialize` ×1 | | this PR | **9 passed** / 9 | n/a | No new tests are added. The existing render tests for these pages already cover the first-render calls. ### Validation - `Aspire.Dashboard.Components.Tests` full suite: 1161 passed, 0 failed. ## Checklist - Is this feature complete? - [x] Yes. Ready to ship. - Are you including unit tests for the changes and scenario tests if relevant? - [x] No. Test-only fix; existing render tests cover it. - Did you add public API? - [x] No - Does the change make any security assumptions or guarantees? - [x] No - Does the change require an update in our Aspire docs? - [x] No **🤖 Posted by AspireAgent (AI Agent)** --------- Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 3aba71331081215598d987aebae2da32beed8faf) --- .../Pages/StructuredLogsTests.cs | 2 +- .../Pages/TraceDetailsTests.cs | 2 +- .../Pages/TracesTests.cs | 2 +- .../Shared/FluentUISetupHelpers.cs | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) 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..404bce2e1f7 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs @@ -1180,7 +1180,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); From b5f39d1d534a73bd842baa2ffa7a3b82bc8302a9 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 10 Nov 2026 04:01:55 -0800 Subject: [PATCH 03/12] Fix flaky TerminalWebSocketTests final-output drain: deliver queued producer output before ending (#21471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes an intermittent failure in `TerminalWebSocketTests.BrowserView_DrainsFinalOutputAndGraphicsBeforeDisconnect`. The browser receives `Close` while it is still waiting for the frame titled `Final screen`. ### Evidence Dashboard `ci.yml` first attempts, Oct 16–30 (main push and PR): - **Ubuntu: 2 failures out of 389 completed attempts**, both `useGrpc: True`: - [job 122963540707](https://github.com/microsoft/aspire/actions/runs/40728167754/job/122963540707) - [job 123253486333](https://github.com/microsoft/aspire/actions/runs/40814645838/job/123253486333) - Windows had 0 failures of this test in 383 completed attempts. Signature: `ReadUntilAsync` expected `Binary` but got `Close`. The server logged no drain timeout, so the dashboard drained successfully, but on a mirror frame that never contained the final output. No open issue, PR or quarantine covers this test. ### Root cause (test fixture) 1. The test writes the final output, waits for `WaitForProducerTextAsync("completed")`, then calls `EndTerminalAsync(includeHmpExit: true)`, which disposes the producer's `Hmp1PresentationAdapter`. 2. `WaitForProducerTextAsync` only proves that the *producer* terminal applied the bytes. In Hex1b 0.173.0, `Hmp1PresentationAdapter.WriteOutputAsync` only enqueues output on each viewer's channel. A separate write pump delivers it to the transport. 3. `DisposeAsync` → `DisposeSessionAsync` completes the channel and **discards queued, undelivered output**. 4. If the write pump hadn't flushed yet, the final lines, OSC title and kitty graphics never reached the dashboard mirror. The mirror then correctly drained a stale frame and closed. Hex1b's graceful path is `TerminalCompleted(exitCode)`, which queues HMP Exit behind every queued output ("Exit must follow every queued output/replay write on the same pump"). ### Fix - `EndTerminalAsync(includeHmpExit: true)` now ends the producer like an exited workload: 1. Call `Presentation.TerminalCompleted(0)`. 2. Wait until the Exit frame has been written to every viewer transport (or the transport closed). 3. Then dispose. - The producer's server-side output stream tracks HMP frame headers to observe Exit. - This replaces the fixture's synthetic gRPC Exit frame: the real Exit now flows through the HMP stream ahead of `Ended`. - `includeHmpExit: false` still models an abrupt producer end. - No product code, assertions or timeouts changed, and there are no sleeps or retries. ### Regression check - Adds `slowProducerDelivery` cases. They delay each producer transport write by 500 ms after the initial frame, so output is deterministically still queued when the terminal ends. - Results were collected locally on Linux (net11.0 Debug): | | slowProducerDelivery cases (both transports) | Existing cases | |---|---|---| | Before (fence bypassed) | **20/20 failed** (`Expected: Binary, Actual: Close`) | 20/20 passed | | After | **40/40 passed** (20 runs) | 40/40 passed | - Full `TerminalWebSocketTests` class: 16 runs (2 parallel processes × 8), **0 failures, 67/67 tests each run**. - `Aspire.Dashboard.Playwright.Tests` (which shares `TerminalTestProducer.cs`) builds. ### Not addressed here `BrowserView_ProducerDisconnectClosesBrowserWhileWaitingForAcknowledgement` failed 3 times in 383 completed Windows first attempts (`useGrpc: True`). It has a different signature: the server's 2 s close handshake times out and aborts, and the client's `CloseOutputAsync` then fails with WSAECONNRESET. It isn't related to producer output delivery. I couldn't attribute it deterministically to a code-level cause, so it is left out of this PR. ## Checklist - Is this feature complete? - [x] Yes. Ready to ship. - [ ] No. Follow-up changes expected. - Are you including unit tests for the changes and scenario tests if relevant? - [x] Yes - [ ] No - Did you add public API? - [ ] Yes - [x] No - Does the change make any security assumptions or guarantees? - [ ] Yes - [x] No **🤖 Posted by AspireAgent (AI Agent)** Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit cbb4f016f461723cb02caf3f7edbf64b325f3e88) --- .../Shared/TerminalTestHost.cs | 31 ++--- .../Shared/TerminalTestProducer.cs | 111 +++++++++++++++++- .../Terminal/TerminalWebSocketTests.cs | 18 ++- 3 files changed, 136 insertions(+), 24 deletions(-) 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 090ebcd2e55..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); From 242fea05532e787237918370943ddc38c4b6d845 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 8 Nov 2026 02:45:26 -0800 Subject: [PATCH 04/12] Read resource row state at every density in dashboard browser tests (#21924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The scheduled [Outerloop Tests run on main](https://github.com/microsoft/aspire/actions/runs/42437120205) failed in two jobs after #21880: - [Templates (ubuntu-latest)](https://github.com/microsoft/aspire/actions/runs/42437120205/job/128616874013): all 10 `StarterTemplateWithRedisCacheTests` and `StarterTemplateWithRedisCacheTests_Net11` tests timed out waiting for `.resource-row-sub > .state-dot + span` in `DashboardResourceAssertions.CheckResourcesAsync`. - [Hosting.Testing (ubuntu-latest)](https://github.com/microsoft/aspire/actions/runs/42437120205/job/128616874062): `DashboardUrlTests.DashboardDisplaysResourceUpdatesWhileAppHostIsPausedAtBreakpoint` couldn't find the text "Dashboard test initial" in the resource row. The row itself was there. ## Failure stats (14d, attempt 1) `Outerloop Tests` workflow on microsoft/aspire, runs created 2026-10-25 06:28 → 2026-11-08 06:28 UTC, **attempt 1 only**. Denominator: attempt-1 jobs that ran (success or failure). Hits: the failed job's log matches the signature. Jobs that lost their runner, which have no log, are excluded. | | | |---|---| | Signature | Templates: `.resource-row-sub > .state-dot + span` timeout; Hosting.Testing: `Dashboard test initial` not found | | Job | Outerloop Tests: Templates (ubuntu-latest) and Hosting.Testing (ubuntu-latest), main | | main pushes: hits / runs | 2 / 28 (7.14%) | | main scheduled runs, 30d: hits / jobs | 2 / 60 (3.33%), both on 2026-11-08. Templates also failed on main every scheduled run from 2026-10-10 to 2026-11-04 with other causes | | PR runs: hits / runs | n/a (Outerloop PR runs don't run these jobs) | | Combined rate | 2 / 28 = **7.14%** | | Flake check | main: failed on all 4 attempts of the run (not a flake) | | First / last hit | 2026-11-08 / 2026-11-08 | | Still hitting main? | Hit on the latest scheduled main run (2026-11-08); no main run since | | Fix evidence | Local: default-density helper tests fail with the old helper and pass with the new one. Outerloop dispatched on this PR's head: Templates and Hosting.Testing (ubuntu) passed 2/2 attempts. | Failing attempt-1 jobs, most recent first (2 of 2 log-verified hits): | Date (UTC) | Branch | Job | Signature excerpt | |---|---|---|---| | [2026-11-08 02:06](https://github.com/microsoft/aspire/actions/runs/42437120205/job/128604467928) | **main** | Hosting.Testing (ubuntu-latest) | `GetByText("Dashboard test initial", Exact) element(s) not found` | | [2026-11-08 02:06](https://github.com/microsoft/aspire/actions/runs/42437120205/job/128604468061) | **main** | Templates (ubuntu-latest) | `.resource-row-sub > .state-dot + span timeout` | **Verdict: STRONG (real break, not a flake).** The 2026-11-08 scheduled main run failed both jobs on **all 4 attempts** with these signatures. The prior scheduled runs (2026-11-06 and 2026-11-07) passed, and the signature never appeared earlier in the window. Earlier Templates failures (2026-10-27 to 2026-11-04) had other causes. **Why:** #21880 deliberately brought back single-line rows for **Compact**, the dashboard's default density. A compact row shows the state as a dot in a `.resource-row-meta-state` column, and the state's name is in that column's `data-tooltip`. There's no `.resource-row-sub` and no visible state text. These browser tests open the dashboard at its default density, so they no longer found the state. `Aspire.Dashboard.Playwright.Tests` passed because `PlaywrightTestsBase` pins those tests to Comfortable unless they opt into the default. This is a deterministic break, not a flake: the previous scheduled runs on main ([42327544779](https://github.com/microsoft/aspire/actions/runs/42327544779), before #21880) passed, and the only dashboard change in between is #21880. **Fix:** the tests now match the intended markup, and no assertions are dropped: - `DashboardResourceAssertions.GetRowStateAsync` reads a row's state at any density. In compact rows it reads the state column's tooltip. In other densities it reads the text after the dot. `RowState(row, state)` finds the element that shows a given state. - `CheckResourcesAsync` uses `GetRowStateAsync`, and keeps waiting while a row hasn't rendered a state yet. - `DashboardDisplaysResourceUpdatesWhileAppHostIsPausedAtBreakpoint` waits for the initial and then the updated state with `RowState`. - `TemplateResourceAssertionsTests.TemplateHelper_ValidatesSidebarAndDetailsWithoutCards` now runs at both the default (compact) density and Comfortable, and checks `RowState` directly, so the helper is covered against the real dashboard at the density the template tests use. ## Confidence - Locally, the 6 `TemplateResourceAssertionsTests` pass. With the old helper, the two new default-density cases fail with the same `Timeout ... exceeded` as the Templates job. - [Outerloop Tests dispatched on this branch](https://github.com/microsoft/aspire/actions/runs/42450827141) to cover the Templates and Hosting.Testing jobs end to end. I didn't run those two locally; they need the full template build and container runtime. **How we stop this recurring:** the shared helper's own test now runs at the dashboard's default density, so a markup change to default rows fails that test against the dashboard alone, which is fast to run locally while changing row markup and points straight at the helper. It's still an outerloop test, so it doesn't gate PR CI. ## Checklist - Is this feature complete? - [x] Yes. Ready to ship. - [ ] No. Follow-up changes expected. - Are you including unit tests for the changes and scenario tests if relevant? - [x] Yes - [ ] No - Did you add public API? - [ ] Yes - [x] No - Does the change make any security assumptions or guarantees? - [ ] Yes - [x] No **🤖 Posted by AspireAgent (AI Agent)** Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 01d9682d38aa93b9df020fbc767529ca96096893) --- .../TemplateResourceAssertionsTests.cs | 19 ++++++++-- .../DashboardUrlTests.cs | 4 +- .../Playwright/DashboardResourceAssertions.cs | 37 ++++++++++++++++++- 3 files changed, 53 insertions(+), 7 deletions(-) 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.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); From f40673ce2a3177651082e1d6a55767fa4876e161 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Thu, 12 Nov 2026 16:09:51 -0800 Subject: [PATCH 05/12] Fix resource sidebar hover state after selection (#22228) From https://github.com/microsoft/aspire/pull/22228, which is still open against main (2 commits, applied as one). Co-Authored-By: Claude Opus 5.5 --- .../Layout/ResourcesLayout.razor.css | 19 +++--- .../Playwright/ResourcePaneResponsiveTests.cs | 62 +++++++++++++++---- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css index 7ec21cfc50b..dc245086c25 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css @@ -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,11 +2832,11 @@ 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); } } diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs index 5cff1929d95..4266773f265 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)] @@ -1747,21 +1757,22 @@ 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, - rail.querySelectorAll('.resource-row-highlighted').length]; + row.matches(':hover') || row.querySelector(':focus-visible, .resource-row-actions [aria-haspopup][aria-expanded="true"]') ? 1 : 0, + rail.querySelectorAll('.resource-row-highlighted').length, + row.classList.contains('resource-row-kind-parameter') ? 1 : 0]; }) """; var resourceRows = pane.Locator(".resource-row[data-resource-name]"); @@ -1781,7 +1792,8 @@ await page.WaitForFunctionAsync(""" } else { - Assert.Equal(1, values[3]); + // Parameter rows have no overflow menu in the expanded list. + Assert.Equal(fullCanvas && values[8] == 1 ? 0 : 1, values[3]); if (!fullCanvas) { var expectedRailWidth = compactSidebar ? (values[7] > 1 ? 94 : 88) : 112; @@ -1796,7 +1808,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 +1964,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 +2027,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 +2076,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 +2216,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 +2228,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 +2251,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); From cdc98c900eb6dac7cdf1e4de7b526d6724754397 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Thu, 12 Nov 2026 16:09:53 -0800 Subject: [PATCH 06/12] Keep resource picker popup width consistent (#22229) From https://github.com/microsoft/aspire/pull/22229, which is still open against main (2 commits, applied as one). Co-Authored-By: Claude Opus 5.5 --- .../Components/Layout/ResourcesLayout.razor.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css index dc245086c25..06aaf1ed8d1 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; From f87af160d474d1a1a345262bd5c975792dcfb90e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 13 Nov 2026 07:41:49 +0800 Subject: [PATCH 07/12] Fix inconsistent dashboard action icon colors (#22142) (cherry picked from commit ebc3bc921b151d577ed923032de02da4a4d27f4e) --- .../Components/Controls/ResourceActions.razor | 4 +- .../Components/Layout/ResourcesLayout.razor | 11 ++--- .../Layout/ResourcesLayoutTests.cs | 44 +++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) 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/Layout/ResourcesLayout.razor b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor index 17b40fe157d..e7f6b5009e8 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor @@ -505,6 +505,7 @@ } + @* Resolved icons are shared. Set the component color rather than mutating the cached icon with WithColor. *@ @foreach (var command in showLabels ? GetHighlightedCommands(resource) : _isPaneFullCanvas ? GetFullPageRowCommands(resource) : GetSidebarCommands(resource)) { var commandTitle = !string.IsNullOrEmpty(command.GetDisplayDescription()) ? command.GetDisplayDescription() : command.GetDisplayName(); @@ -512,9 +513,9 @@ { + @command.GetDisplayName() } @@ -523,12 +524,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 +538,7 @@ 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() { From 92f8969cd1861f980f18baacdc0c9bc5a3f7be57 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Thu, 12 Nov 2026 16:18:41 -0800 Subject: [PATCH 08/12] Render the trace log preview only for the marker that's hovered or focused Each log marker on the trace waterfall rendered its own hidden preview, a title and a three-row table, for the shared tooltip to clone. A span can have every retained log, up to 100,000 by default, so a log-heavy trace built well over a million hidden elements, whether or not any were opened. The page now renders one shared preview. Pointing at a marker or focusing it fills that preview with the marker's entry, and the tooltip, which follows its content's changes, shows it. The handler targets the preview, so only it renders again on hover, not the whole waterfall. Co-Authored-By: Claude Opus 5.5 --- .../Components/Controls/SpanLogPreview.razor | 28 ++++++++ .../Controls/SpanLogPreview.razor.cs | 45 ++++++++++++ .../Components/Pages/TraceDetail.razor | 27 ++----- .../Components/Pages/TraceDetail.razor.cs | 7 ++ .../Controls/SpanLogPreviewTests.cs | 70 +++++++++++++++++++ .../Pages/TraceDetailsTests.cs | 58 +++++++-------- 6 files changed, 181 insertions(+), 54 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor create mode 100644 src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Controls/SpanLogPreviewTests.cs 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/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/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/Pages/TraceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs index 404bce2e1f7..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] From e52735989f169b6720ba3c3c6824f66517412c92 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Thu, 12 Nov 2026 16:29:13 -0800 Subject: [PATCH 09/12] Center tag group chevrons over the state dot column In the compact sidebar at rest, the rows' state dots form a column at the end of the list. The group header's chevron sat 13px past it, so center it over that column. Replaces #21996. Co-Authored-By: Claude Opus 5.5 --- .../Components/Layout/ResourcesLayout.razor.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css index 06aaf1ed8d1..53bca5855b9 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css @@ -2839,6 +2839,15 @@ .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, From d55a135bd3522084292e6e9ae300dd8109e63667 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Thu, 12 Nov 2026 16:37:00 -0800 Subject: [PATCH 10/12] Fix two resource pane Playwright tests on the 17.0 stack Rows_KeepDensity still expected parameter rows in the expanded list to have no overflow menu, but every row has one now, parameters included. FilterAndGrouping could hover a button before the feedback prompt appeared above the header. The prompt pushes the page down 40px, leaving the pointer over the brand, so its tooltip showed instead. Wait for the prompt first. Co-Authored-By: Claude Opus 5.5 --- .../Playwright/ResourcePaneResponsiveTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs index 4266773f265..09adceb612a 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs @@ -650,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(); @@ -1771,8 +1775,7 @@ await page.WaitForFunctionAsync(""" essential.every(shown) ? 1 : 0, [...rail.querySelectorAll('.resource-row-action')].some(shown) ? 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, - row.classList.contains('resource-row-kind-parameter') ? 1 : 0]; + rail.querySelectorAll('.resource-row-highlighted').length]; }) """; var resourceRows = pane.Locator(".resource-row[data-resource-name]"); @@ -1792,8 +1795,9 @@ await page.WaitForFunctionAsync(""" } else { - // Parameter rows have no overflow menu in the expanded list. - Assert.Equal(fullCanvas && values[8] == 1 ? 0 : 1, values[3]); + // 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) { var expectedRailWidth = compactSidebar ? (values[7] > 1 ? 94 : 88) : 112; From f628ff43f95a87b5d2c494139983a01c5644b0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:53:54 -0800 Subject: [PATCH 11/12] Go home with Escape, as the pages' close button did Pages lost their close button when the resource table became the home page (#22188), and Escape went with it. Escape now does what the brand does, from every page beside the docked list: it opens the table of every resource at "/". The key is left to whatever else uses it: text fields, the terminal, open menus, popovers, dialogs and shown tooltips. It doesn't go home from home itself, on narrow layouts (no table there), without the resource service, or while home is already loading. When a tab has keyboard focus, Escape takes focus off it, so the strip re-created at home doesn't give the tab focus back with its keyboard focus ring. --- .../Components/Layout/ResourcesLayout.razor | 1 + .../Layout/ResourcesLayout.razor.cs | 8 ++ .../Layout/ResourcesLayout.razor.js | 87 +++++++++++++++++++ .../Layout/ResourcesLayoutHomeTests.cs | 39 ++++++++- .../Playwright/ResourcePaneFullCanvasTests.cs | 62 +++++++++++++ 5 files changed, 195 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor index e7f6b5009e8..ce0f0f105bf 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. *@ 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.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/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.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); From 9703d994da37b9e58784558980004d618c985eb2 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Thu, 12 Nov 2026 17:01:28 -0800 Subject: [PATCH 12/12] Give labeled resource action icons their button's text color The overview's Stop button and its menu button drew their icons in the accent color while their labels used the text color, so the icon read as separate from its button. Every action icon now takes its button's text color, labeled or not. Co-Authored-By: Claude Opus 5.5 --- .../Components/Layout/ResourcesLayout.razor | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor index ce0f0f105bf..d352fe9057b 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor @@ -506,7 +506,9 @@ } - @* Resolved icons are shared. Set the component color rather than mutating the cached icon with WithColor. *@ + @* 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(); @@ -516,7 +518,7 @@ data-tooltip="@commandTitle" OnClick="@handlers.Command(command.Name)" Disabled="@IsCommandDisabled(resource, command)"> - + @command.GetDisplayName() } @@ -539,7 +541,6 @@