From 13cbd9ff33b5183d2677c7e4051728af8221f530 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 24 Jul 2026 01:24:26 +0000 Subject: [PATCH] Add a user-facing Cancel control for the scenario Open-from-folder scan (#656) The scenario "Open from folder" scan (folder enumeration plus a parallel per-file channel probe) was only cancellable by disposing the dashboard. This adds a persistent masthead status chip that surfaces the scan and a Cancel control that stays reachable regardless of which scenario or category tab is selected. Service (ScenarioLaunchService): LaunchFromFolderAsync gains an optional Func? onPhase callback (defaulted null, so existing callers are unaffected). It reports Scanning once the picker returns a folder (cancellation is only meaningful from that point) and Opening once a nonempty match commits to opening logs, with a cancellation recheck after the callback so a cancel that lands during the commit still wins before any filter dispatch or log open. Dashboard (EmptyStateDashboard): a per-launch CancellationTokenSource linked to the component lifetime, a three-state masthead chip (Scanning with Cancel, Cancelling..., Opening logs...), focus management that keeps the Cancel button reachable and never strands keyboard focus on the document body, and guarded rendering so the busy state is reflected on the banner-retry path (which runs outside the dashboard event loop). The reactive-launch banner "Open from folder" retry is routed through the guarded wrapper so it no longer bypasses the busy guard. Tests: service-level phase-emission tests (Scanning/Opening boundaries and cancellation) plus bUnit tests covering chip visibility, decoupling from selection, cancellation acknowledgement, the Opening relabel, and the banner-retry busy-state regression. --- .../Scenarios/IScenarioLaunchService.cs | 8 +- .../Scenarios/ScenarioFolderPhase.cs | 20 ++ .../Scenarios/ScenarioLaunchService.cs | 15 +- .../Dashboard/EmptyStateDashboard.razor | 22 +- .../Dashboard/EmptyStateDashboard.razor.cs | 153 ++++++++- .../Dashboard/EmptyStateDashboard.razor.css | 10 + .../Scenarios/ScenarioLaunchServiceTests.cs | 312 ++++++++++++------ .../Dashboard/EmptyStateDashboardTests.cs | 212 +++++++++++- 8 files changed, 633 insertions(+), 119 deletions(-) create mode 100644 src/EventLogExpert.Runtime/Scenarios/ScenarioFolderPhase.cs diff --git a/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs index 20aff4da..c90ac7d5 100644 --- a/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs @@ -21,7 +21,13 @@ public interface IScenarioLaunchService /// works even for logs not present on the local host. The folder enumeration and per-file channel probe run off the /// caller's thread and honor , returning /// if the scan is abandoned before any log is opened. + /// , when supplied, is invoked as the operation crosses each + /// boundary so a caller can show a cancellable busy indicator only while + /// cancellation is still meaningful. /// Task LaunchFromFolderAsync( - ScenarioDefinition scenario, DateFilter? dateWindow, CancellationToken cancellationToken = default); + ScenarioDefinition scenario, + DateFilter? dateWindow, + CancellationToken cancellationToken = default, + Func? onPhase = null); } diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioFolderPhase.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioFolderPhase.cs new file mode 100644 index 00000000..71f71ed7 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioFolderPhase.cs @@ -0,0 +1,20 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +/// +/// A progress phase reported by so a caller can +/// surface a cancellable busy indicator only while cancellation is meaningful. +/// +public enum ScenarioFolderPhase +{ + /// The folder picker has returned a folder and the cancellable enumeration/probe scan is starting. + Scanning, + + /// + /// The scan matched logs and is committing to open them. Past this point the operation is no longer cancellable, + /// so a caller should stop offering Cancel. + /// + Opening +} diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs index b13abf98..9e36f7c6 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs @@ -77,7 +77,8 @@ public async Task LaunchAsync( public async Task LaunchFromFolderAsync( ScenarioDefinition scenario, DateFilter? dateWindow, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? onPhase = null) { ArgumentNullException.ThrowIfNull(scenario); @@ -94,6 +95,10 @@ public async Task LaunchFromFolderAsync( if (folder is null) { return ScenarioFolderLaunchResult.Cancelled; } + // A real folder was chosen, so the cancellable enumeration/probe scan is about to start. Signal Scanning here + // (not before the picker) so a caller shows its Cancel affordance only once cancellation is meaningful. + if (onPhase is not null) { await onPhase(ScenarioFolderPhase.Scanning); } + FolderMatch match; try @@ -137,6 +142,14 @@ public async Task LaunchFromFolderAsync( // logs, while a genuine fault surfacing from the open still propagates (the commit stays outside the catch). if (cancellationToken.IsCancellationRequested) { return ScenarioFolderLaunchResult.Cancelled; } + // The scan matched logs and is committing to open them; signal Opening so a caller can drop its Cancel + // affordance (the open is not cancellable). Gate on a nonempty match so Opening is never reported for an + // outcome that opens nothing, then recheck cancellation -- independently of whether a callback was supplied -- + // so a cancellation that lands while the callback runs still wins before any filter dispatch or open. + if (match.Paths.Count > 0 && onPhase is not null) { await onPhase(ScenarioFolderPhase.Opening); } + + if (cancellationToken.IsCancellationRequested) { return ScenarioFolderLaunchResult.Cancelled; } + return await OpenMatchedLogsAsync(scenario, dateWindow, match); } diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor index 85a9f6b4..3a6af378 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor @@ -1,6 +1,6 @@ @inherits FluxorComponent -
+
@@ -10,6 +10,26 @@
+ @if (_scanningScenario is { } scanning) + { +
+ + @if (_openingLogs) + { + Opening logs for @scanning.Name... + } + else if (_cancelRequested) + { + Cancelling folder scan for @scanning.Name... + } + else + { + Scanning @scanning.Name folder for logs... + + + } +
+ } @if (FilterApplied.Value) {
diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs index 2514dee8..03955ced 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs @@ -13,6 +13,8 @@ using EventLogExpert.Runtime.Scenarios.Favorites; using EventLogExpert.Scenarios.Catalog; using EventLogExpert.UI.Common; +using EventLogExpert.UI.Focus; +using EventLogExpert.UI.Inputs; using EventLogExpert.UI.Modal; using Fluxor; using Fluxor.Blazor.Web.Components; @@ -43,12 +45,21 @@ public sealed partial class EmptyStateDashboard : FluxorComponent private readonly Dictionary _selectedByCategory = new(); private SplashCategory _activeCategory; + private bool _cancelRequested; + private Button? _cancelScanButton; private List<(SplashCategory Category, string Label, IReadOnlyList Scenarios)> _categories = []; + private ElementReference _dashboardRoot; + private bool _disposed; + private CancellationTokenSource? _folderLaunchCts; private bool _isBusy; private LivePresence _livePresence = new(false, FrozenSet.Empty); + private bool _openingLogs; + private bool _pendingCancelFocus; + private bool _pendingScanEndFocus; private bool _pendingTabFocus; private IReadOnlyDictionary _readinessByChannel = new Dictionary(StringComparer.OrdinalIgnoreCase); + private ScenarioDefinition? _scanningScenario; private SidebarTabs? _sidebarTabs; private IReadOnlyList? _splashScenarios; private IReadOnlyList<(SplashCategory Tab, string Label)> _tabs = []; @@ -112,6 +123,7 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) { + _disposed = true; Favorites.SelectedValueChanged -= OnFavoritesChanged; await _lifetimeCts.CancelAsync(); _lifetimeCts.Dispose(); @@ -122,7 +134,21 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) protected override async Task OnAfterRenderAsync(bool firstRender) { - if (_pendingTabFocus && _sidebarTabs is not null) + if (_pendingCancelFocus) + { + _pendingCancelFocus = false; + + if (_cancelScanButton is not null) { await ElementFocus.SafelyAsync(_cancelScanButton.Element); } + } + else if (_pendingScanEndFocus) + { + _pendingScanEndFocus = false; + + var focused = _sidebarTabs is not null && await _sidebarTabs.FocusActiveTabAsync(); + + if (!focused) { await ElementFocus.SafelyAsync(_dashboardRoot); } + } + else if (_pendingTabFocus && _sidebarTabs is not null) { _pendingTabFocus = false; @@ -249,6 +275,20 @@ private bool AccessAllowsLaunch(string channel) => new ChannelReadiness(channel, ChannelPresence.Unknown, ChannelEnablement.Unknown)) .Access is ChannelAccess.Accessible or ChannelAccess.NotEvaluated; + private void CancelFolderScan() + { + // No-op once the scan has committed to opening or a cancel is already in flight. The null-field guard plus the + // null-before-Dispose ordering in the finally means Cancel() is never called on a disposed source. + if (_openingLogs || _cancelRequested || _folderLaunchCts is null) { return; } + + _cancelRequested = true; + _pendingCancelFocus = false; + _pendingTabFocus = false; + _pendingScanEndFocus = true; + _folderLaunchCts.Cancel(); + StateHasChanged(); + } + private void ClearFilter() => FilterCommands.ClearAllFilters(); private Task EnableChannelAsync(string channel) => @@ -321,7 +361,7 @@ await AlertDialogService.ShowErrorAlert( "Launch scenario", message, "Open from folder", - () => LaunchScenarioFromFolderCoreAsync(scenario)); + () => LaunchScenarioFromFolderAsync(scenario)); } else { @@ -334,9 +374,43 @@ private Task LaunchScenarioFromFolderAsync(ScenarioDefinition scenario) => private async Task LaunchScenarioFromFolderCoreAsync(ScenarioDefinition scenario) { - var result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null, _lifetimeCts.Token); + var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token); + _folderLaunchCts = cts; + var scanStarted = false; + ScenarioFolderLaunchResult result; - if (DescribeFolderLaunch(scenario, result) is not { } message) { return; } + try + { + result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null, cts.Token, OnFolderScanPhaseAsync); + } + finally + { + if (ReferenceEquals(_folderLaunchCts, cts)) { _folderLaunchCts = null; } + + cts.Dispose(); + + if (_scanningScenario is not null) + { + // Hide the scan chip before any result dialog. The banner-retry path does not run in the dashboard's + // event loop, so it will not auto-render; the explicit render is required there. Clear the scan-end + // focus intent too, so an Opening-set request cannot steal focus from a result modal opened below. + _scanningScenario = null; + _openingLogs = false; + _cancelRequested = false; + _pendingCancelFocus = false; + _pendingScanEndFocus = false; + + await SafeInvokeAsync(StateHasChanged); + } + } + + if (DescribeFolderLaunch(scenario, result) is not { } message) + { + // Cancelled: no dialog takes focus and the Cancel chip that had focus is gone, so restore it. + if (scanStarted) { RestoreFocusAfterScan(); } + + return; + } // A launch that opens logs is self-evident (the workspace changes) and only needs the screen-reader detail; // the outcomes that leave the dashboard unchanged need a visible dialog so a sighted user sees them. @@ -346,12 +420,44 @@ private async Task LaunchScenarioFromFolderCoreAsync(ScenarioDefinition scenario Announcer.Announce(message); break; case ScenarioFolderOutcome.Error: + // ShowErrorAlert surfaces a banner, which does not capture focus; restore it so a scan that showed the + // Cancel chip does not strand keyboard focus on . + if (scanStarted) { RestoreFocusAfterScan(); } + await AlertDialogService.ShowErrorAlert("Open from folder", message); break; default: + // ShowAlert opens a focus-capturing modal, so it owns focus; issuing a restore here would fight it. await AlertDialogService.ShowAlert("Open from folder", message, "OK"); break; } + + // Runs on the UI dispatcher via SafeInvokeAsync and must never throw out of onPhase: it executes outside the + // service's cancellation catch, so an escaping teardown exception would surface a normal launch as a fault. + async Task OnFolderScanPhaseAsync(ScenarioFolderPhase phase) => + await SafeInvokeAsync(() => + { + switch (phase) + { + case ScenarioFolderPhase.Scanning: + scanStarted = true; + _scanningScenario = scenario; + _openingLogs = false; + _cancelRequested = false; + _pendingTabFocus = false; + _pendingScanEndFocus = false; + _pendingCancelFocus = true; + break; + case ScenarioFolderPhase.Opening: + _openingLogs = true; + _pendingCancelFocus = false; + _pendingTabFocus = false; + _pendingScanEndFocus = true; + break; + } + + StateHasChanged(); + }); } private async void OnFavoritesChanged(object? _, ImmutableHashSet __) @@ -465,14 +571,49 @@ private async Task RefreshReadinessAsync() _livePresence = LivePresence.FromReadiness(readiness); } + private void RestoreFocusAfterScan() + { + if (_disposed) { return; } + + _pendingCancelFocus = false; + _pendingTabFocus = false; + _pendingScanEndFocus = true; + StateHasChanged(); + } + private async Task RunGuardedAsync(Func action) { if (_isBusy) { return; } _isBusy = true; - try { await action(); } - finally { _isBusy = false; } + try + { + // Render immediately (inside the try, so the finally still clears _isBusy if this ever threw) so the + // busy-gated controls disable right away. On a normal button click Blazor auto-renders at the first await + // inside the action, but the banner-retry path runs outside the dashboard's event loop and gets no + // automatic render, so without this the controls would stay visibly enabled during the folder picker. + await SafeInvokeAsync(StateHasChanged); + await action(); + } + finally + { + _isBusy = false; + + // Re-render after clearing the busy flag for the same non-event-loop reason, so the controls re-enable. + await SafeInvokeAsync(StateHasChanged); + } + } + + private async Task SafeInvokeAsync(Action render) + { + if (_disposed) { return; } + + // Matches the OnFavoritesChanged teardown pattern: a render can race the dashboard unmounting when a folder + // launch opens logs; treat disposal and cancellation as expected. + try { await InvokeAsync(render); } + catch (ObjectDisposedException) { } + catch (OperationCanceledException) { } } private IReadOnlyList ScenariosFor(SplashCategory category) diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.css b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.css index c108c372..5c2d3b59 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.css +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.css @@ -61,6 +61,16 @@ color: var(--clr-yellow); } +/* Progress accent for the folder-scan chip, distinct from the yellow filter-applied warning chip. + Placed after the base rules so it wins on equal specificity when both classes are present. */ +.empty-dashboard__chip--scanning { + border-left-color: var(--clr-statusbar); +} + +.empty-dashboard__chip--scanning > .bi { + color: var(--clr-statusbar); +} + .empty-dashboard__chip-text { color: var(--text-primary); } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs index 693c2bb4..c3db1d3d 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs @@ -19,41 +19,50 @@ namespace EventLogExpert.Runtime.Tests.Scenarios; public sealed class ScenarioLaunchServiceTests { [Fact] - public async Task LaunchAsync_ActivatingScenario_WhenTimelineAlreadyVisible_DoesNotReshow() + public async Task LaunchAsync_ActivatingScenarioWithTimelineDimension_DispatchesDimensionRequest() { - var (service, _, menu, scenario) = - Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true, timelineVisible: true); + var (service, dispatcher, _, scenario) = Create( + new OpenLogsBatchResult(1, 0, 0, 0, []), + activatesTimeline: true, + timelineDimension: ScenarioTimelineDimension.ErrorCode); await service.LaunchAsync(scenario, dateWindow: null); - menu.DidNotReceive().SetHistogramVisible(Arg.Any()); + dispatcher.Received(1).Dispatch( + Arg.Is(action => action != null && action.Dimension == HistogramDimension.ErrorCode)); } [Fact] - public async Task LaunchAsync_ActivatingScenario_WhenTimelineHidden_ShowsTimeline() + public async Task LaunchAsync_ActivatingScenarioWithTimelineDimension_WhenAlreadyVisible_DispatchesDimensionRequest() { - var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true); + var (service, dispatcher, _, scenario) = Create( + new OpenLogsBatchResult(1, 0, 0, 0, []), + activatesTimeline: true, + timelineVisible: true, + timelineDimension: ScenarioTimelineDimension.EventId); await service.LaunchAsync(scenario, dateWindow: null); - menu.Received(1).SetHistogramVisible(true); + dispatcher.Received(1).Dispatch( + Arg.Is(action => action != null && action.Dimension == HistogramDimension.EventId)); } [Fact] - public async Task LaunchAsync_ActivatingScenario_ZeroOpenButCombining_ShowsTimeline() + public async Task LaunchAsync_ActivatingScenarioWithoutTimelineDimension_DoesNotDispatchDimensionRequest() { - var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(0, 0, 0, 1, []), activatesTimeline: true); + var (service, dispatcher, _, scenario) = + Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true); - await service.LaunchAsync(scenario, dateWindow: null, combineLog: true); + await service.LaunchAsync(scenario, dateWindow: null); - menu.Received(1).SetHistogramVisible(true); + dispatcher.DidNotReceive().Dispatch(Arg.Any()); } [Fact] - public async Task LaunchAsync_ActivatingScenario_ZeroOpenFreshView_DoesNotShowTimeline() + public async Task LaunchAsync_ActivatingScenario_WhenTimelineAlreadyVisible_DoesNotReshow() { var (service, _, menu, scenario) = - Create(new OpenLogsBatchResult(0, 1, 0, 0, ["System"]), activatesTimeline: true); + Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true, timelineVisible: true); await service.LaunchAsync(scenario, dateWindow: null); @@ -61,43 +70,34 @@ public async Task LaunchAsync_ActivatingScenario_ZeroOpenFreshView_DoesNotShowTi } [Fact] - public async Task LaunchAsync_ActivatingScenarioWithoutTimelineDimension_DoesNotDispatchDimensionRequest() + public async Task LaunchAsync_ActivatingScenario_WhenTimelineHidden_ShowsTimeline() { - var (service, dispatcher, _, scenario) = - Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true); + var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true); await service.LaunchAsync(scenario, dateWindow: null); - dispatcher.DidNotReceive().Dispatch(Arg.Any()); + menu.Received(1).SetHistogramVisible(true); } [Fact] - public async Task LaunchAsync_ActivatingScenarioWithTimelineDimension_DispatchesDimensionRequest() + public async Task LaunchAsync_ActivatingScenario_ZeroOpenButCombining_ShowsTimeline() { - var (service, dispatcher, _, scenario) = Create( - new OpenLogsBatchResult(1, 0, 0, 0, []), - activatesTimeline: true, - timelineDimension: ScenarioTimelineDimension.ErrorCode); + var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(0, 0, 0, 1, []), activatesTimeline: true); - await service.LaunchAsync(scenario, dateWindow: null); + await service.LaunchAsync(scenario, dateWindow: null, combineLog: true); - dispatcher.Received(1).Dispatch( - Arg.Is(action => action != null && action.Dimension == HistogramDimension.ErrorCode)); + menu.Received(1).SetHistogramVisible(true); } [Fact] - public async Task LaunchAsync_ActivatingScenarioWithTimelineDimension_WhenAlreadyVisible_DispatchesDimensionRequest() + public async Task LaunchAsync_ActivatingScenario_ZeroOpenFreshView_DoesNotShowTimeline() { - var (service, dispatcher, _, scenario) = Create( - new OpenLogsBatchResult(1, 0, 0, 0, []), - activatesTimeline: true, - timelineVisible: true, - timelineDimension: ScenarioTimelineDimension.EventId); + var (service, _, menu, scenario) = + Create(new OpenLogsBatchResult(0, 1, 0, 0, ["System"]), activatesTimeline: true); await service.LaunchAsync(scenario, dateWindow: null); - dispatcher.Received(1).Dispatch( - Arg.Is(action => action != null && action.Dimension == HistogramDimension.EventId)); + menu.DidNotReceive().SetHistogramVisible(Arg.Any()); } [Fact] @@ -343,6 +343,86 @@ public async Task LaunchFromFolderAsync_ActivatingScenarioWhenHidden_ShowsTimeli menu.Received(1).SetHistogramVisible(true); } + [Fact] + public async Task LaunchFromFolderAsync_ActivatingScenarioWithTimelineDimension_DispatchesDimensionRequest() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = + CreateFolder(FolderScenario() with + { + ActivatesTimeline = true, + TimelineDimension = ScenarioTimelineDimension.Log + }); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); + + dispatcher.Received(1).Dispatch( + Arg.Is(action => action != null && action.Dimension == HistogramDimension.Log)); + } + + [Fact] + public async Task LaunchFromFolderAsync_ActivatingScenarioWithTimelineDimension_WhenAlreadyVisible_DispatchesDimensionRequest() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = + CreateFolder( + FolderScenario() with + { + ActivatesTimeline = true, + TimelineDimension = ScenarioTimelineDimension.Source + }, + timelineVisible: true); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); + + dispatcher.Received(1).Dispatch( + Arg.Is(action => action != null && action.Dimension == HistogramDimension.Source)); + } + + [Fact] + public async Task LaunchFromFolderAsync_ActivatingScenarioWithoutTimelineDimension_DoesNotDispatchDimensionRequest() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = + CreateFolder(FolderScenario() with { ActivatesTimeline = true }); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); + + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_CancelledDuringEnumeration_ReportsScanningButNotOpening() + { + var (service, _, _, picker, enumerator, _, scenario) = CreateFolder(FolderScenario()); + using var cts = new CancellationTokenSource(); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()) + .Returns(_ => + { + cts.Cancel(); + + return EvtxFolderScanResult.Empty.Instance; + }); + var phases = new List(); + + var result = await service.LaunchFromFolderAsync( + scenario, dateWindow: null, cts.Token, phase => { phases.Add(phase); return Task.CompletedTask; }); + + // Scanning is emitted before enumeration, so a mid-scan cancellation leaves it already reported; Opening is not. + Assert.Equal(ScenarioFolderOutcome.Cancelled, result.Outcome); + Assert.Equal([ScenarioFolderPhase.Scanning], phases); + } + [Fact] public async Task LaunchFromFolderAsync_CancelledDuringEnumeration_ReturnsCancelledWithoutOpening() { @@ -409,63 +489,6 @@ public async Task LaunchFromFolderAsync_CancelledToken_ReturnsCancelledWithoutOp dispatcher.DidNotReceive().Dispatch(Arg.Any()); } - [Fact] - public async Task LaunchFromFolderAsync_ActivatingScenarioWithoutTimelineDimension_DoesNotDispatchDimensionRequest() - { - var (service, dispatcher, menu, picker, enumerator, reader, scenario) = - CreateFolder(FolderScenario() with { ActivatesTimeline = true }); - picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); - reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); - - await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); - - dispatcher.DidNotReceive().Dispatch(Arg.Any()); - } - - [Fact] - public async Task LaunchFromFolderAsync_ActivatingScenarioWithTimelineDimension_DispatchesDimensionRequest() - { - var (service, dispatcher, menu, picker, enumerator, reader, scenario) = - CreateFolder(FolderScenario() with - { - ActivatesTimeline = true, - TimelineDimension = ScenarioTimelineDimension.Log - }); - picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); - reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); - - await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); - - dispatcher.Received(1).Dispatch( - Arg.Is(action => action != null && action.Dimension == HistogramDimension.Log)); - } - - [Fact] - public async Task LaunchFromFolderAsync_ActivatingScenarioWithTimelineDimension_WhenAlreadyVisible_DispatchesDimensionRequest() - { - var (service, dispatcher, menu, picker, enumerator, reader, scenario) = - CreateFolder( - FolderScenario() with - { - ActivatesTimeline = true, - TimelineDimension = ScenarioTimelineDimension.Source - }, - timelineVisible: true); - picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); - reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); - - await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); - - dispatcher.Received(1).Dispatch( - Arg.Is(action => action != null && action.Dimension == HistogramDimension.Source)); - } - [Fact] public async Task LaunchFromFolderAsync_CompletedWithUnreadableFile_PlumbsUnreadableCount() { @@ -510,23 +533,6 @@ public async Task LaunchFromFolderAsync_EnumerationAccessDenied_ReturnsError() Assert.Equal("access denied", result.Message); } - [Fact] - public async Task LaunchFromFolderAsync_MatchedButNoneOpened_RollsBackAndReturnsNoLogsOpened() - { - var (service, dispatcher, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); - picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); - reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(0, 1, 0, 0, [])); - - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); - - Assert.Equal(ScenarioFolderOutcome.NoLogsOpened, result.Outcome); - Assert.Equal(1, result.Empty); - dispatcher.Received(1).Dispatch(Arg.Any()); - menu.DidNotReceive().SetHistogramVisible(Arg.Any()); - } - [Fact] public async Task LaunchFromFolderAsync_MatchOpened_ReturnsCompletedAndAppliesFiltersToMatchedFiles() { @@ -554,6 +560,23 @@ await menu.Received(1).OpenLogFilesAsync( showInlineAlerts: false); } + [Fact] + public async Task LaunchFromFolderAsync_MatchedButNoneOpened_RollsBackAndReturnsNoLogsOpened() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(0, 1, 0, 0, [])); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); + + Assert.Equal(ScenarioFolderOutcome.NoLogsOpened, result.Outcome); + Assert.Equal(1, result.Empty); + dispatcher.Received(1).Dispatch(Arg.Any()); + menu.DidNotReceive().SetHistogramVisible(Arg.Any()); + } + [Fact] public async Task LaunchFromFolderAsync_NoChannelMatch_ReturnsNoMatchingLogsWithMissingChannel() { @@ -608,6 +631,81 @@ public async Task LaunchFromFolderAsync_PickerThrows_ReturnsError() Assert.Equal("picker boom", result.Message); } + [Fact] + public async Task LaunchFromFolderAsync_WhenCancelledDuringOpeningPhase_ReturnsCancelledWithoutOpening() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + using var cts = new CancellationTokenSource(); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + // A cancellation that lands while the Opening callback runs must win: the post-callback recheck returns Cancelled + // before any filter dispatch or log open. + var result = await service.LaunchFromFolderAsync( + scenario, + dateWindow: null, + cts.Token, + phase => + { + if (phase == ScenarioFolderPhase.Opening) { cts.Cancel(); } + + return Task.CompletedTask; + }); + + Assert.Equal(ScenarioFolderOutcome.Cancelled, result.Outcome); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_WhenNoChannelMatch_ReportsScanningButNotOpening() + { + var (service, _, _, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\other.evtx"])); + reader.ReadChannel("C:\\bundle\\other.evtx").Returns(EvtxChannelReadResult.FromChannel("Application")); + var phases = new List(); + + var result = await service.LaunchFromFolderAsync( + scenario, dateWindow: null, TestContext.Current.CancellationToken, phase => { phases.Add(phase); return Task.CompletedTask; }); + + // Zero matched paths never open anything, so Opening (gated on a nonempty match) is never reported. + Assert.Equal(ScenarioFolderOutcome.NoMatchingLogs, result.Outcome); + Assert.Equal([ScenarioFolderPhase.Scanning], phases); + } + + [Fact] + public async Task LaunchFromFolderAsync_WhenPickerCancelled_ReportsNoPhase() + { + var (service, _, _, picker, _, _, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns((string?)null); + var phases = new List(); + + await service.LaunchFromFolderAsync( + scenario, dateWindow: null, TestContext.Current.CancellationToken, phase => { phases.Add(phase); return Task.CompletedTask; }); + + Assert.Empty(phases); + } + + [Fact] + public async Task LaunchFromFolderAsync_WhenScanMatchesAndOpens_ReportsScanningThenOpening() + { + var (service, _, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + var phases = new List(); + + var result = await service.LaunchFromFolderAsync( + scenario, dateWindow: null, TestContext.Current.CancellationToken, phase => { phases.Add(phase); return Task.CompletedTask; }); + + Assert.Equal(ScenarioFolderOutcome.Completed, result.Outcome); + Assert.Equal([ScenarioFolderPhase.Scanning, ScenarioFolderPhase.Opening], phases); + } + [Theory] [InlineData(ScenarioTimelineDimension.Severity, HistogramDimension.Severity)] [InlineData(ScenarioTimelineDimension.Source, HistogramDimension.Source)] diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs index c599c1ae..6de254fa 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs @@ -293,7 +293,7 @@ public void DetailLaunch_WhenScenarioRequiresAdminButLivePresent_StaysLaunchable [Fact] public void DetailOpenFromFolder_WhenCompleted_AnnouncesWithoutAlert() { - _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(new ScenarioFolderLaunchResult { Outcome = ScenarioFolderOutcome.Completed, Opened = 1, Matched = 1 }); _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); @@ -312,7 +312,7 @@ public void DetailOpenFromFolder_WhenCompleted_AnnouncesWithoutAlert() [Fact] public void DetailOpenFromFolder_WhenError_ShowsErrorAlert() { - _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(ScenarioFolderLaunchResult.Error("access denied")); _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); @@ -327,7 +327,7 @@ public void DetailOpenFromFolder_WhenError_ShowsErrorAlert() [Fact] public void DetailOpenFromFolder_WhenNoMatchingLogs_ShowsVisibleAlert() { - _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(new ScenarioFolderLaunchResult { Outcome = ScenarioFolderOutcome.NoMatchingLogs }); _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); @@ -453,6 +453,212 @@ public void Favoriting_ReactivelyAddsFavoritesTab() cut.WaitForAssertion(() => Assert.Contains("Favorites", TabLabels(cut))); } + [Fact] + public void FolderScan_WhenCancelClicked_CancelsTokenAndShowsCancellingWithoutDialog() + { + var release = new TaskCompletionSource(); + CancellationToken captured = default; + _scenarioLaunch + .LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) + .Returns(async ci => + { + captured = ci.Arg(); + await ci.Arg>()!(ScenarioFolderPhase.Scanning); + + return await release.Task; + }); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailOpenFolder))); + cut.Find(ActiveDetailOpenFolder).Click(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".empty-dashboard__chip--scanning button"))); + + cut.Find(".empty-dashboard__chip--scanning button").Click(); + + cut.WaitForAssertion(() => + { + var chip = cut.Find(".empty-dashboard__chip--scanning"); + Assert.Contains("Cancelling", chip.TextContent); + Assert.Empty(cut.FindAll(".empty-dashboard__chip--scanning button")); + }); + Assert.True(captured.IsCancellationRequested); + + // The service ultimately reports Cancelled; no result dialog is shown for a user cancellation. + release.SetResult(ScenarioFolderLaunchResult.Cancelled); + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".empty-dashboard__chip--scanning"))); + _alertDialog.DidNotReceive().ShowAlert(Arg.Any(), Arg.Any(), Arg.Any()); + _alertDialog.DidNotReceive().ShowErrorAlert("Open from folder", Arg.Any()); + } + + [Fact] + public async Task FolderScan_WhenLaunchedFromReactiveBannerRetry_GuardsScanAndReenablesButtons() + { + // A live launch that cannot open its channels raises a banner whose retry opens from a folder. That retry runs + // outside the dashboard's event loop, so it must route through RunGuardedAsync (setting _isBusy while the scan + // runs) AND RunGuardedAsync must render on completion to re-enable the busy-gated controls. + _scenarioLaunch.LaunchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ScenarioLaunchResult(0, 0, 1) + { + ChannelOutcomes = [new ChannelOutcome("System", ChannelLaunchOutcome.NotPresent)] + }); + + Func? retry = null; + _alertDialog.ShowErrorAlert("Launch scenario", Arg.Any(), "Open from folder", Arg.Any?>()) + .Returns(call => + { + retry = call.Arg>(); + + return Task.CompletedTask; + }); + + var pickerGate = new TaskCompletionSource(); + var release = new TaskCompletionSource(); + _scenarioLaunch + .LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) + .Returns(async ci => + { + // Simulate the native folder picker being open before any phase is reported. + await pickerGate.Task; + await ci.Arg>()!(ScenarioFolderPhase.Scanning); + + return await release.Task; + }); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); + + cut.Find(ActiveDetailLaunch).Click(); + cut.WaitForAssertion(() => Assert.NotNull(retry)); + + var retried = cut.InvokeAsync(() => retry!()); + + // During the picker (before any phase) the guard must already have disabled the busy-gated controls, even though + // the scan chip is not shown yet. If the retry bypassed RunGuardedAsync, or RunGuardedAsync did not render on + // _isBusy=true, the open-folder button would still be enabled here. + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll(".empty-dashboard__chip--scanning")); + Assert.True(cut.Find(ActiveDetailOpenFolder).HasAttribute("disabled")); + }); + + // The picker returns; Scanning surfaces the chip while the scan runs, controls still disabled. + pickerGate.SetResult(); + cut.WaitForAssertion(() => + { + Assert.NotEmpty(cut.FindAll(".empty-dashboard__chip--scanning")); + Assert.True(cut.Find(ActiveDetailOpenFolder).HasAttribute("disabled")); + }); + + release.SetResult(new ScenarioFolderLaunchResult { Outcome = ScenarioFolderOutcome.NoMatchingLogs }); + await retried; + + // The completion render re-enables the busy-gated control on this non-event-loop path. + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll(".empty-dashboard__chip--scanning")); + Assert.False(cut.Find(ActiveDetailOpenFolder).HasAttribute("disabled")); + }); + } + + [Fact] + public void FolderScan_WhenOpeningPhase_RelabelsAndDropsCancelButton() + { + var release = new TaskCompletionSource(); + _scenarioLaunch + .LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) + .Returns(async ci => + { + var onPhase = ci.Arg>()!; + await onPhase(ScenarioFolderPhase.Scanning); + await onPhase(ScenarioFolderPhase.Opening); + + return await release.Task; + }); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailOpenFolder))); + cut.Find(ActiveDetailOpenFolder).Click(); + + cut.WaitForAssertion(() => + { + var chip = cut.Find(".empty-dashboard__chip--scanning"); + Assert.Contains("Opening logs", chip.TextContent); + Assert.Empty(cut.FindAll(".empty-dashboard__chip--scanning button")); + }); + + release.SetResult(new ScenarioFolderLaunchResult { Outcome = ScenarioFolderOutcome.Completed, Opened = 1, Matched = 1 }); + } + + [Fact] + public void FolderScan_WhenScanning_ShowsMastheadCancelChip() + { + var release = new TaskCompletionSource(); + _scenarioLaunch + .LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) + .Returns(async ci => + { + await ci.Arg>()!(ScenarioFolderPhase.Scanning); + + return await release.Task; + }); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailOpenFolder))); + Assert.Empty(cut.FindAll(".empty-dashboard__chip--scanning")); + + cut.Find(ActiveDetailOpenFolder).Click(); + + cut.WaitForAssertion(() => + { + var chip = cut.Find(".empty-dashboard__chip--scanning"); + Assert.Contains("Scanning", chip.TextContent); + Assert.Contains("Application crashes", chip.TextContent); + Assert.NotEmpty(cut.FindAll(".empty-dashboard__chip--scanning button")); + }); + + release.SetResult(ScenarioFolderLaunchResult.Cancelled); + } + + [Fact] + public void FolderScan_WhenUserReselectsScenario_CancelChipRemainsInMasthead() + { + var release = new TaskCompletionSource(); + _scenarioLaunch + .LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) + .Returns(async ci => + { + await ci.Arg>()!(ScenarioFolderPhase.Scanning); + + return await release.Task; + }); + _scenarioQuery.GetSplashScenarios().Returns( + [ + Scenario("crash-a", "Crash A", group: ScenarioGroup.SqlServer, order: 0), + Scenario("crash-b", "Crash B", group: ScenarioGroup.SqlServer, order: 1) + ]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.Equal("Crash A", cut.Find(ActiveDetailName).TextContent)); + + cut.Find(ActiveDetailOpenFolder).Click(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".empty-dashboard__chip--scanning"))); + + // Reselecting a different scenario changes the detail pane but must not hide the masthead scan chip. + cut.FindAll(ActiveOption).First(option => option.TextContent.Contains("Crash B")).Click(); + + cut.WaitForAssertion(() => + { + Assert.Equal("Crash B", cut.Find(ActiveDetailName).TextContent); + Assert.NotEmpty(cut.FindAll(".empty-dashboard__chip--scanning")); + }); + + release.SetResult(ScenarioFolderLaunchResult.Cancelled); + } + [Fact] public void ManageDatabases_IsInMastheadAndInvokesOpen() {