From b163985a72999d6849442347b5afe6b494af307f Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sun, 3 May 2026 21:02:15 -0500 Subject: [PATCH] Add filter, virtualization, and export UI to DebugLogModal and unify file save --- .../Modals/DebugLogModalTests.cs | 995 ++++++++++++++++++ .../TestUtils/Constants/Constants.DebugLog.cs | 15 + .../TestUtils/DebugLogUtils.cs | 34 + .../Filters/FilterCategoryEditor.razor | 1 + .../Inputs/ValueSelect.razor.cs | 12 +- .../Inputs/ValueSelectItem.razor.cs | 2 +- .../Modals/DebugLogModal.razor | 98 +- .../Modals/DebugLogModal.razor.cs | 251 ++++- .../Modals/DebugLogModal.razor.css | 65 ++ src/EventLogExpert.Components/_Imports.razor | 1 + .../Services/DebugLogEntryParserTests.cs | 572 ++++++++++ .../Services/DebugLogProjectionTests.cs | 460 ++++++++ .../Services/DebugLogServiceTests.cs | 148 ++- .../Services/ReversedListViewTests.cs | 207 ++++ .../TestUtils/Constants/Constants.DebugLog.cs | 3 + .../Interfaces/IClipboardService.cs | 9 + .../Interfaces/IFileLogger.cs | 2 +- .../Interfaces/IFileSaveService.cs | 45 + src/EventLogExpert.UI/Models/DebugLogEntry.cs | 16 + .../Services/DebugLogEntryParser.cs | 162 +++ .../Services/DebugLogProjection.cs | 87 ++ .../Services/DebugLogService.cs | 108 +- .../Services/ReversedListView.cs | 79 ++ .../Modals/Filters/FilterCacheModal.razor.cs | 34 +- .../Modals/Filters/FilterGroup.razor.cs | 32 +- .../Modals/Filters/FilterGroupModal.razor.cs | 34 +- src/EventLogExpert/MauiProgram.cs | 1 + .../Platforms/Windows/FolderPickerHelper.cs | 14 +- .../Services/MauiFileSaveService.cs | 86 ++ .../Services/MauiMenuActionService.cs | 30 +- src/EventLogExpert/Services/TitleProvider.cs | 13 +- src/EventLogExpert/wwwroot/css/app.css | 4 + 32 files changed, 3485 insertions(+), 135 deletions(-) create mode 100644 src/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs create mode 100644 src/EventLogExpert.Components.Tests/TestUtils/Constants/Constants.DebugLog.cs create mode 100644 src/EventLogExpert.Components.Tests/TestUtils/DebugLogUtils.cs create mode 100644 src/EventLogExpert.Components/Modals/DebugLogModal.razor.css create mode 100644 src/EventLogExpert.UI.Tests/Services/DebugLogEntryParserTests.cs create mode 100644 src/EventLogExpert.UI.Tests/Services/DebugLogProjectionTests.cs create mode 100644 src/EventLogExpert.UI.Tests/Services/ReversedListViewTests.cs create mode 100644 src/EventLogExpert.UI/Interfaces/IFileSaveService.cs create mode 100644 src/EventLogExpert.UI/Models/DebugLogEntry.cs create mode 100644 src/EventLogExpert.UI/Services/DebugLogEntryParser.cs create mode 100644 src/EventLogExpert.UI/Services/DebugLogProjection.cs create mode 100644 src/EventLogExpert.UI/Services/ReversedListView.cs create mode 100644 src/EventLogExpert/Services/MauiFileSaveService.cs diff --git a/src/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs b/src/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs new file mode 100644 index 00000000..da358f8b --- /dev/null +++ b/src/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs @@ -0,0 +1,995 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Components.Modals; +using EventLogExpert.Components.Tests.TestUtils; +using EventLogExpert.Components.Tests.TestUtils.Constants; +using EventLogExpert.UI.Interfaces; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using System.Text; + +namespace EventLogExpert.Components.Tests.Modals; + +public sealed class DebugLogModalTests : BunitContext +{ + private readonly IAlertDialogService _alertDialogService = Substitute.For(); + private readonly IClipboardService _clipboardService = Substitute.For(); + private readonly IFileLogger _fileLogger = Substitute.For(); + private readonly IFileSaveService _fileSaveService = Substitute.For(); + private readonly IModalService _modalService = Substitute.For(); + + public DebugLogModalTests() + { + _modalService.ActiveModalId.Returns(1L); + + Services.AddSingleton(_alertDialogService); + Services.AddSingleton(_clipboardService); + Services.AddSingleton(_fileLogger); + Services.AddSingleton(_fileSaveService); + Services.AddSingleton(_modalService); + Services.AddFluxor(options => options.ScanAssemblies(typeof(DebugLogModal).Assembly)); + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public async Task DebugLogModal_AfterLoad_FooterCounterIsPoliteLiveStatusRegion() + { + // Arrange + var lines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage) }; + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + // Act + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 1 entry", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Assert + var counter = component.Find(".debug-log-footer-counter"); + Assert.Equal("status", counter.GetAttribute("role")); + Assert.Equal("polite", counter.GetAttribute("aria-live")); + Assert.Equal("true", counter.GetAttribute("aria-atomic")); + } + + [Fact] + public async Task DebugLogModal_AfterLoad_ViewportHasRegionRoleWithoutAriaLiveAndIsKeyboardFocusable() + { + // Arrange + var lines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage) }; + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + // Act + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 1 entry", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Assert + var viewport = component.Find(".debug-log-viewport"); + Assert.Equal("region", viewport.GetAttribute("role")); + Assert.Equal("0", viewport.GetAttribute("tabindex")); + Assert.Null(viewport.GetAttribute("aria-live")); + Assert.Equal("Debug log entries", viewport.GetAttribute("aria-label")); + } + + [Fact] + public async Task DebugLogModal_AfterLoad_VirtualizeItemsAreNewestFirstAndPreserveContinuationLineOrder() + { + // Arrange + var firstHeader = DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage); + var secondHeader = DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage); + const string ContinuationLine = " at MyMethod()"; + var thirdHeader = DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogThirdMessage); + + _fileLogger.LoadAsync(Arg.Any()).Returns( + DebugLogUtils.ToAsyncEnumerable([firstHeader, secondHeader, ContinuationLine, thirdHeader])); + + // Act + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Assert + var rows = component.FindAll(".debug-log-row"); + Assert.Equal( + new[] { thirdHeader, secondHeader, ContinuationLine, firstHeader }, + rows.Select(row => row.TextContent).ToArray()); + } + + [Fact] + public async Task DebugLogModal_ClearDuringStreamingLoad_StaleStreamDoesNotMutateState() + { + // Arrange + var gate = new TaskCompletionSource(); + + async IAsyncEnumerable Source() + { + for (var i = 0; i < 100; i++) + { + yield return DebugLogUtils.BuildLine(LogLevel.Information, $"msg-{i}"); + } + + await gate.Task; + + for (var i = 100; i < 130; i++) + { + yield return DebugLogUtils.BuildLine(LogLevel.Information, $"msg-{i}"); + } + } + + _fileLogger.LoadAsync(Arg.Any()).Returns(Source()); + _fileLogger.ClearAsync().Returns(Task.CompletedTask); + + var component = Render(); + + await component.WaitForAssertionAsync( + () => Assert.Equal("99 of 99 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + + // Act + await component.Find("button:contains('Clear')").ClickAsync(new()); + + await component.WaitForAssertionAsync( + () => Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + + gate.SetResult(); + + await Task.Delay(200, Xunit.TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()); + } + + [Fact] + public async Task DebugLogModal_ClearSucceeds_ResetsEntriesAndCounter() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + _fileLogger.ClearAsync().Returns(Task.CompletedTask); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act + await component.Find("button:contains('Clear')").ClickAsync(new()); + + // Assert + await _fileLogger.Received(1).ClearAsync(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + await _alertDialogService.DidNotReceive().ShowAlert( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DebugLogModal_ClearThrows_PreservesEntriesAndShowsAlert() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + _fileLogger.ClearAsync().ThrowsAsync(new InvalidOperationException("permission denied")); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act + await component.Find("button:contains('Clear')").ClickAsync(new()); + + // Assert: alert shown and counter unchanged (UI state preserved on failure) + await _alertDialogService.Received(1).ShowAlert("Clear Failed", "permission denied", "OK"); + + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()); + } + + [Fact] + public async Task DebugLogModal_CopyClick_CallsCopyTextAsyncWithEnvironmentNewLineJoinedDisplayed() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + var expectedContent = string.Join(Environment.NewLine, new[] { lines[1], lines[0] }); + + // Act + await component.Find("button:contains('Copy')").ClickAsync(new()); + + // Assert + await _clipboardService.Received(1).CopyTextAsync(expectedContent); + } + + [Fact] + public async Task DebugLogModal_CopyClickWhilePendingFilterPending_FlushesFilterBeforeReadingDisplayed() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogErrorMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act — typed text doesn't apply until the 250ms debounce; Copy must flush first. + await component.Find("input[aria-label='Filter messages']").InputAsync(new() { Value = "error" }); + await component.Find("button:contains('Copy')").ClickAsync(new()); + + // Assert: clipboard receives only the entry whose Message contains "error". + await _clipboardService.Received(1).CopyTextAsync(lines[1]); + await _clipboardService.DidNotReceive().CopyTextAsync( + Arg.Is(s => s.Contains(Constants.DebugLogFirstMessage))); + } + + [Fact] + public async Task DebugLogModal_DuringLoad_RendersFirstBatchBeforeStreamCompletes() + { + // Arrange — gate stops the stream at 100 lines so the modal must paint the in-flight batch. + var gate = new TaskCompletionSource(); + + async IAsyncEnumerable Source() + { + for (var i = 0; i < 100; i++) + { + yield return DebugLogUtils.BuildLine(LogLevel.Information, $"msg-{i}"); + } + + await gate.Task; + + for (var i = 100; i < 130; i++) + { + yield return DebugLogUtils.BuildLine(LogLevel.Information, $"msg-{i}"); + } + } + + _fileLogger.LoadAsync(Arg.Any()).Returns(Source()); + + // Act + var component = Render(); + + // Assert — streaming parser buffers one entry pending; 100 lines surface as 99. + await component.WaitForAssertionAsync( + () => Assert.Equal("99 of 99 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + + gate.SetResult(); + + await component.WaitForAssertionAsync( + () => Assert.Equal("130 of 130 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task DebugLogModal_EmptyLog_CopyButtonIsDisabled() + { + // Arrange + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act + Assert + var copyButton = component.Find("button:contains('Copy')"); + + Assert.True(copyButton.HasAttribute("disabled")); + } + + [Fact] + public async Task DebugLogModal_EmptyLog_ExportButtonIsDisabled() + { + // Arrange + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act + Assert + var exportButton = component.Find("button:contains('Export')"); + + Assert.True(exportButton.HasAttribute("disabled")); + } + + [Fact] + public async Task DebugLogModal_EmptyLog_ShowsLogIsEmptyMessageAndZeroCounter() + { + // Arrange + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); + + // Act + var component = Render(); + + // Assert + await component.WaitForAssertionAsync(() => + Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + Assert.Contains("Log is Empty...", component.Markup); + } + + [Fact] + public async Task DebugLogModal_EmptyLogWithActiveFilter_StillShowsLogIsEmptyMessage() + { + // Arrange + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act: type a filter while the log is empty + await component.Find("input[aria-label='Filter messages']").InputAsync(new() { Value = "any-search-text" }); + + // Assert — zero entries (not zero filtered); "No entries match filters" would mislead. + await component.WaitForAssertionAsync(() => + { + Assert.Contains("Log is Empty...", component.Markup); + Assert.DoesNotContain("No entries match filters.", component.Markup); + }); + } + + [Fact] + public async Task DebugLogModal_ExportClick_CallsSaveAsyncWithEnvironmentNewLineJoinedDisplayed() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + Func? capturedWriter = null; + + _fileSaveService.SaveAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Do>(writer => capturedWriter = writer)) + .Returns(Task.FromResult("C:\\debug-log.log")); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + var expectedContent = string.Join(Environment.NewLine, new[] { lines[1], lines[0] }); + + // Act + await component.Find("button:contains('Export')").ClickAsync(new()); + + // Assert + await _fileSaveService.Received(1).SaveAsync( + Arg.Is(name => name.StartsWith("debug-log-") && name.EndsWith(".log")), + FileSaveServiceFileTypes.Log, + Arg.Any>()); + + Assert.NotNull(capturedWriter); + Assert.Equal(expectedContent, await InvokeWriterAndDecodeAsync(capturedWriter)); + } + + [Fact] + public async Task DebugLogModal_ExportClickWhilePendingFilterPending_FlushesFilterBeforeReadingDisplayed() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogErrorMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + Func? capturedWriter = null; + + _fileSaveService.SaveAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Do>(writer => capturedWriter = writer)) + .Returns(Task.FromResult("C:\\debug-log.log")); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act: type a filter that drops two entries, then click Export before debounce expires. + await component.Find("input[aria-label='Filter messages']").InputAsync(new() { Value = "error" }); + await component.Find("button:contains('Export')").ClickAsync(new()); + + // Assert + await _fileSaveService.Received(1).SaveAsync( + Arg.Any(), + FileSaveServiceFileTypes.Log, + Arg.Any>()); + + Assert.NotNull(capturedWriter); + Assert.Equal(lines[1], await InvokeWriterAndDecodeAsync(capturedWriter)); + } + + [Fact] + public async Task DebugLogModal_ExportReturnsNull_DoesNotShowAlert() + { + // Arrange + var lines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogTestMessage) }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + _fileSaveService.SaveAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any>()) + .Returns(Task.FromResult(null)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 1 entry", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act + await component.Find("button:contains('Export')").ClickAsync(new()); + + // Assert + await _alertDialogService.DidNotReceive().ShowAlert( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DebugLogModal_ExportThrows_ShowsExportFailedAlert() + { + // Arrange + var lines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogTestMessage) }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + _fileSaveService.SaveAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any>()) + .ThrowsAsync(new InvalidOperationException("disk full")); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 1 entry", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act + await component.Find("button:contains('Export')").ClickAsync(new()); + + // Assert + await _alertDialogService.Received(1).ShowAlert("Export Failed", "disk full", "OK"); + } + + [Fact] + public async Task DebugLogModal_FilterMatchesNoEntries_ShowsNoEntriesMatchFiltersMessage() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act + await component.Find("input[aria-label='Filter messages']").InputAsync(new() { Value = "no-such-text" }); + + // Assert + await component.WaitForAssertionAsync( + () => Assert.Equal("0 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + + Assert.Contains("No entries match filters.", component.Markup); + } + + [Fact] + public async Task DebugLogModal_LevelDropdownInitial_AllOptionShowsAriaSelectedTrueAndSpecificLevelsDoNot() + { + // Arrange + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); + + // Act + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Assert + var allOption = component.FindAll("[role='option']").Single(o => o.TextContent.Trim() == "All"); + Assert.Equal("true", allOption.GetAttribute("aria-selected")); + + var traceOption = component.FindAll("[role='option']").Single(o => o.TextContent.Trim() == nameof(LogLevel.Trace)); + Assert.Equal("false", traceOption.GetAttribute("aria-selected")); + } + + [Fact] + public async Task DebugLogModal_LevelFilterChangedMidStream_RemainingBatchesUseNewFilter() + { + // Arrange + var gate = new TaskCompletionSource(); + + async IAsyncEnumerable Source() + { + for (var i = 0; i < 100; i++) + { + yield return DebugLogUtils.BuildLine(LogLevel.Information, $"info-{i}"); + } + + await gate.Task; + + for (var i = 100; i < 130; i++) + { + yield return DebugLogUtils.BuildLine(LogLevel.Error, $"error-{i}"); + } + } + + _fileLogger.LoadAsync(Arg.Any()).Returns(Source()); + + var component = Render(); + + await component.WaitForAssertionAsync( + () => Assert.Equal("99 of 99 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + + // Act + var levelDropdown = component.Find("input[aria-label='Level']").ParentElement!; + var errorOption = levelDropdown.QuerySelectorAll("[role='option']") + .First(o => o.TextContent.Trim() == "Error"); + + await errorOption.MouseDownAsync(new()); + + await component.WaitForAssertionAsync( + () => Assert.Equal("0 of 99 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + + gate.SetResult(); + + // Assert + await component.WaitForAssertionAsync( + () => Assert.Equal("30 of 130 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task DebugLogModal_LevelFilterChangedWhilePendingFilterPending_FlushesPendingTextFilter() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, "matching info"), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + DebugLogUtils.BuildLine(LogLevel.Error, "matching error"), + DebugLogUtils.BuildLine(LogLevel.Error, Constants.DebugLogThirdMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("5 of 5 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act — type a string filter (debounce starts), then change the level dropdown before it elapses. + await component.Find("input[aria-label='Filter messages']").InputAsync(new() { Value = "matching" }); + + var levelDropdown = component.Find("input[aria-label='Level']").ParentElement!; + var errorOption = levelDropdown.QuerySelectorAll("[role='option']") + .First(o => o.TextContent.Trim() == "Error"); + + await errorOption.MouseDownAsync(new()); + + // Assert — only the entry that matches BOTH filters survives. + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 5 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + } + + [Fact] + public async Task DebugLogModal_LevelFilterEqualsError_NarrowsToOneEntry() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Error, Constants.DebugLogErrorMessage), + DebugLogUtils.BuildLine(LogLevel.Warning, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act: click "Error" in the level value dropdown + var levelDropdown = component.Find("input[aria-label='Level']").ParentElement!; + var errorOption = levelDropdown.QuerySelectorAll("[role='option']") + .First(o => o.TextContent.Trim() == "Error"); + + await errorOption.MouseDownAsync(new()); + + // Assert + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + } + + [Fact] + public async Task DebugLogModal_LevelFilterMultiSelectErrorAndWarning_KeepsBoth() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Error, Constants.DebugLogErrorMessage), + DebugLogUtils.BuildLine(LogLevel.Warning, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act: switch operator to "Multi Select" + var operatorDropdown = component.Find("input[aria-label='Level operator']").ParentElement!; + var multiSelectOption = operatorDropdown.QuerySelectorAll("[role='option']") + .First(o => o.TextContent.Trim() == "Multi Select"); + + await multiSelectOption.MouseDownAsync(new()); + + // Then pick Error and Warning from the multi-select dropdown + var levelsDropdown = component.Find("input[aria-label='Levels']").ParentElement!; + + var errorOption = levelsDropdown.QuerySelectorAll("[role='option']") + .First(o => o.TextContent.Trim() == "Error"); + + await errorOption.MouseDownAsync(new()); + + var warningOption = levelsDropdown.QuerySelectorAll("[role='option']") + .First(o => o.TextContent.Trim() == "Warning"); + + await warningOption.MouseDownAsync(new()); + + // Assert: Error and Warning remain; Information is filtered out + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + } + + [Fact] + public async Task DebugLogModal_LevelFilterNotEqualInformation_HidesInformationEntries() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Error, Constants.DebugLogErrorMessage), + DebugLogUtils.BuildLine(LogLevel.Warning, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act: switch operator to "Not Equal" + var operatorDropdown = component.Find("input[aria-label='Level operator']").ParentElement!; + var notEqualOption = operatorDropdown.QuerySelectorAll("[role='option']") + .First(o => o.TextContent.Trim() == "Not Equal"); + + await notEqualOption.MouseDownAsync(new()); + + // Then select "Information" as the value to exclude + var levelDropdown = component.Find("input[aria-label='Level']").ParentElement!; + var informationOption = levelDropdown.QuerySelectorAll("[role='option']") + .First(o => o.TextContent.Trim() == "Information"); + + await informationOption.MouseDownAsync(new()); + + // Assert: Error and Warning remain (Information is the only level excluded) + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + } + + [Fact] + public async Task DebugLogModal_LoadLifecycle_ViewportAriaBusyTogglesFromTrueToFalse() + { + // Arrange + var loadGate = new TaskCompletionSource(); + + async IAsyncEnumerable Source() + { + await loadGate.Task; + yield return DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage); + } + + _fileLogger.LoadAsync(Arg.Any()).Returns(Source()); + + // Act + var component = Render(); + + // Assert: in-flight (aria-busy=true) + await component.WaitForAssertionAsync(() => + Assert.Equal("true", component.Find(".debug-log-viewport").GetAttribute("aria-busy"))); + + loadGate.SetResult(); + + // Assert: settled (aria-busy=false) + await component.WaitForAssertionAsync(() => + Assert.Equal("false", component.Find(".debug-log-viewport").GetAttribute("aria-busy"))); + } + + [Fact] + public async Task DebugLogModal_PopulatedLog_CounterShowsTotalEntries() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Error, Constants.DebugLogErrorMessage), + DebugLogUtils.BuildLine(LogLevel.Warning, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + // Act + var component = Render(); + + // Assert + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + } + + [Fact] + public async Task DebugLogModal_RefreshClickedWhilePendingFilterPending_UsesNewFilterForReloadedEntries() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, "matching"), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act — type a filter (debounce starts), then click Refresh before it elapses. + await component.Find("input[aria-label='Filter messages']").InputAsync(new() { Value = "matching" }); + await component.Find("button:contains('Refresh')").ClickAsync(new()); + + // Assert — the reload re-projects with the just-typed filter, not the prior empty one. + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + } + + [Fact] + public async Task DebugLogModal_RefreshLoadAsyncThrows_ProjectsPartialDataAndShowsAlert() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns( + DebugLogUtils.YieldThenThrow(lines, new InvalidOperationException("read failed"))); + + // Act + var component = Render(); + + // Assert: partial entries projected and alert shown (no rethrow to caller) + await component.WaitForAssertionAsync(() => + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + await component.WaitForAssertionAsync(() => + _alertDialogService.Received(1).ShowAlert("Refresh Failed", "read failed", "OK")); + } + + [Fact] + public async Task DebugLogModal_StringFilterAfterDebounce_NarrowsToMatchingEntries() + { + // Arrange + var lines = new[] + { + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogErrorMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), + }; + + _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable(lines)); + + var component = Render(); + + await component.WaitForAssertionAsync(() => + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Act + await component.Find("input[aria-label='Filter messages']").InputAsync(new() { Value = "error" }); + + // Assert (wait long enough for the 250ms debounce + projection) + await component.WaitForAssertionAsync( + () => Assert.Equal("1 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()), + TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task DebugLogModal_WhenRefreshIsRestartedWhileLoading_CancelsInFlightLoad() + { + // Arrange + var firstStartedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancellationObservedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var fastLines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage) }; + + _fileLogger.LoadAsync(Arg.Any()) + .Returns( + call => HangingSource(call.Arg(), firstStartedTcs, cancellationObservedTcs), + _ => DebugLogUtils.ToAsyncEnumerable(fastLines)); + + var component = Render(); + + await firstStartedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5), Xunit.TestContext.Current.CancellationToken); + + // Act - clicking Refresh starts a new load, which cancels the in-flight one + await component.Find(".debug-log-footer-right button:first-child").ClickAsync(new()); + + // Assert + await cancellationObservedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5), Xunit.TestContext.Current.CancellationToken); + + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 1 entry", component.Find(".debug-log-footer-counter").TextContent.Trim())); + } + + [Fact] + public async Task DebugLogModal_WhenStaleProviderEmitsAfterRestart_DoesNotMutateDisplayedState() + { + // Arrange + var firstStartedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseStaleEmissionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var staleDisposedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var staleResumedAfterYieldCount = 0; + + var fastLines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage) }; + + _fileLogger.LoadAsync(Arg.Any()) + .Returns( + _ => NonCooperativeSource( + firstStartedTcs, + releaseStaleEmissionTcs, + staleDisposedTcs, + () => Interlocked.Increment(ref staleResumedAfterYieldCount)), + _ => DebugLogUtils.ToAsyncEnumerable(fastLines)); + + var component = Render(); + + await firstStartedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5), Xunit.TestContext.Current.CancellationToken); + + await component.Find(".debug-log-footer-right button:first-child").ClickAsync(new()); + + await component.WaitForAssertionAsync(() => + Assert.Equal("1 of 1 entry", component.Find(".debug-log-footer-counter").TextContent.Trim())); + + // Release stale provider; guard should dispose after next yield (signalled via finally). + releaseStaleEmissionTcs.TrySetResult(); + + await staleDisposedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5), Xunit.TestContext.Current.CancellationToken); + + // Counter unchanged; orphaned stale Refresh cannot mutate visible state. + Assert.Equal("1 of 1 entry", component.Find(".debug-log-footer-counter").TextContent.Trim()); + + // Guard rejected first post-release yield; flood not consumed. + Assert.Equal(1, staleResumedAfterYieldCount); + } + + private static async IAsyncEnumerable HangingSource( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken, + TaskCompletionSource startedTcs, + TaskCompletionSource cancelledTcs) + { + yield return DebugLogUtils.BuildLine(LogLevel.Information, "first"); + startedTcs.TrySetResult(); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + cancelledTcs.TrySetResult(); + throw; + } + } + + private static async Task InvokeWriterAndDecodeAsync(Func writer) + { + using var stream = new MemoryStream(); + + await writer(stream); + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static async IAsyncEnumerable NonCooperativeSource( + TaskCompletionSource startedTcs, + TaskCompletionSource releaseTcs, + TaskCompletionSource disposedTcs, + Action onResumedAfterYield) + { + try + { + yield return DebugLogUtils.BuildLine(LogLevel.Information, "stale-first"); + onResumedAfterYield(); + startedTcs.TrySetResult(); + + // Deliberately ignore cancellation by awaiting a token-less wait. + await releaseTcs.Task; + + // Flood >RenderBatchSize so projection would run absent the generation guard. + for (var i = 0; i < 150; i++) + { + yield return DebugLogUtils.BuildLine(LogLevel.Information, $"stale-flood-{i}"); + onResumedAfterYield(); + } + } + finally + { + disposedTcs.TrySetResult(); + } + } +} diff --git a/src/EventLogExpert.Components.Tests/TestUtils/Constants/Constants.DebugLog.cs b/src/EventLogExpert.Components.Tests/TestUtils/Constants/Constants.DebugLog.cs new file mode 100644 index 00000000..9fc85fff --- /dev/null +++ b/src/EventLogExpert.Components.Tests/TestUtils/Constants/Constants.DebugLog.cs @@ -0,0 +1,15 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Components.Tests.TestUtils.Constants; + +public sealed partial class Constants +{ + public const string DebugLogErrorMessage = "Error message"; + public const string DebugLogFirstMessage = "First message"; + public const string DebugLogSecondMessage = "Second message"; + public const string DebugLogTestMessage = "Test message"; + public const int DebugLogTestThreadId = 12; + public const string DebugLogTestTimestamp = "2026-04-29T07:53:20.9321852-04:00"; + public const string DebugLogThirdMessage = "Third message"; +} diff --git a/src/EventLogExpert.Components.Tests/TestUtils/DebugLogUtils.cs b/src/EventLogExpert.Components.Tests/TestUtils/DebugLogUtils.cs new file mode 100644 index 00000000..27d30b7e --- /dev/null +++ b/src/EventLogExpert.Components.Tests/TestUtils/DebugLogUtils.cs @@ -0,0 +1,34 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace EventLogExpert.Components.Tests.TestUtils; + +public static class DebugLogUtils +{ + public static string BuildLine(LogLevel level, string message) => + $"[{Constants.Constants.DebugLogTestTimestamp}] [{Constants.Constants.DebugLogTestThreadId}] [{level}] {message}"; + + public static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable lines) + { + foreach (var line in lines) + { + yield return line; + + await Task.Yield(); + } + } + + public static async IAsyncEnumerable YieldThenThrow(IEnumerable lines, Exception exception) + { + foreach (var line in lines) + { + yield return line; + + await Task.Yield(); + } + + throw exception; + } +} diff --git a/src/EventLogExpert.Components/Filters/FilterCategoryEditor.razor b/src/EventLogExpert.Components/Filters/FilterCategoryEditor.razor index 38b487fa..d0944d96 100644 --- a/src/EventLogExpert.Components/Filters/FilterCategoryEditor.razor +++ b/src/EventLogExpert.Components/Filters/FilterCategoryEditor.razor @@ -27,6 +27,7 @@ else if (Data.Evaluator == FilterEvaluator.MultiSelect) { All @foreach (var item in Items) diff --git a/src/EventLogExpert.Components/Inputs/ValueSelect.razor.cs b/src/EventLogExpert.Components/Inputs/ValueSelect.razor.cs index 70b18da0..c2793718 100644 --- a/src/EventLogExpert.Components/Inputs/ValueSelect.razor.cs +++ b/src/EventLogExpert.Components/Inputs/ValueSelect.razor.cs @@ -45,6 +45,14 @@ public ValueSelectItem? HighlightedItem [Parameter] public bool IsMultiSelect { get; set; } + /// + /// Text shown by a multi-select when no values are selected. + /// Defaults to "Empty"; consumers should override with a domain-appropriate label such as + /// "All" when an empty selection means "no filter applied". + /// + [Parameter] + public string EmptyText { get; set; } = "Empty"; + private string? DisplayString { get @@ -56,7 +64,7 @@ private string? DisplayString return converter is null ? $"{Value}" : converter.Set(Value); } - if (Values.Count <= 0) { return "Empty"; } + if (Values.Count <= 0) { return EmptyText; } return converter is null ? string.Join(", ", Values.Select(x => $"{x}")) : @@ -105,6 +113,8 @@ public async ValueTask DisposeAsync() } } + public bool HasAnySelection => _selectedValues.Count > 0; + public bool IsItemSelected(T value) => _selectedValues.Contains(value); public async Task OpenDropDown() => await JSRuntime.InvokeVoidAsync("openDropdown", _selectComponent); diff --git a/src/EventLogExpert.Components/Inputs/ValueSelectItem.razor.cs b/src/EventLogExpert.Components/Inputs/ValueSelectItem.razor.cs index 81157729..33c0a703 100644 --- a/src/EventLogExpert.Components/Inputs/ValueSelectItem.razor.cs +++ b/src/EventLogExpert.Components/Inputs/ValueSelectItem.razor.cs @@ -41,7 +41,7 @@ private string? DisplayString private bool IsHighlighted => _parent.HighlightedItem?.Equals(this) ?? false; - private bool IsSelected => _parent.IsItemSelected(Value); + private bool IsSelected => ClearItem ? !_parent.HasAnySelection : _parent.IsItemSelected(Value); [CascadingParameter] private ValueSelect ValueSelect diff --git a/src/EventLogExpert.Components/Modals/DebugLogModal.razor b/src/EventLogExpert.Components/Modals/DebugLogModal.razor index 226d9aa2..871b0ac7 100644 --- a/src/EventLogExpert.Components/Modals/DebugLogModal.razor +++ b/src/EventLogExpert.Components/Modals/DebugLogModal.razor @@ -1,35 +1,107 @@ @inherits ModalBase +@using EventLogExpert.UI +@using Microsoft.Extensions.Logging -
- @if (_data.Count > 0) +
+ + + + + + + @if (_levelOperator == FilterEvaluator.MultiSelect) { - @for (var i = _data.Count - 1; i >= 0; i--) - { - @_data[i] -
- } + + All + @foreach (var level in s_logLevels) + { + + } + } - else if (_hasLoaded) + else { - Log is Empty... + + All + @foreach (var level in s_logLevels) + { + + } + } + +
+
+ + +
@line
+
+ + @if (_hasLoaded) + { + @(_entries.Count == 0 ? "Log is Empty..." : "No entries match filters.") + } + +
-
- - + diff --git a/src/EventLogExpert.Components/Modals/DebugLogModal.razor.cs b/src/EventLogExpert.Components/Modals/DebugLogModal.razor.cs index 4556a653..c48bb3b0 100644 --- a/src/EventLogExpert.Components/Modals/DebugLogModal.razor.cs +++ b/src/EventLogExpert.Components/Modals/DebugLogModal.razor.cs @@ -2,27 +2,70 @@ // // Licensed under the MIT License. using EventLogExpert.Components.Base; +using EventLogExpert.UI; using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using EventLogExpert.UI.Services; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; namespace EventLogExpert.Components.Modals; public sealed partial class DebugLogModal : ModalBase { private const int RenderBatchSize = 100; + private const int RowHeightPx = 18; + private const int StringFilterDebounceMs = 250; - private readonly List _data = []; + private static readonly LogLevel[] s_logLevels = + [ + LogLevel.Trace, + LogLevel.Debug, + LogLevel.Information, + LogLevel.Warning, + LogLevel.Error, + LogLevel.Critical, + ]; + private string _activeStringFilter = string.Empty; + private List _displayed = []; + private ReversedListView _displayedView; + private IReadOnlyList _entries = []; + private int _filteredEntryCount; private bool _hasLoaded; + private FilterEvaluator _levelOperator = FilterEvaluator.Equals; + private CancellationTokenSource? _loadCts; private int _loadGeneration; + private List _multiLevels = []; + private string _pendingStringFilter = string.Empty; + private int _projectedThroughIndex; + private LogLevel? _singleLevel; + private CancellationTokenSource? _stringFilterCts; + private int _stringFilterGeneration; + + public DebugLogModal() + { + _displayedView = new ReversedListView(_displayed); + } + + [Inject] private IAlertDialogService AlertDialogService { get; set; } = null!; + + [Inject] private IClipboardService ClipboardService { get; set; } = null!; [Inject] private IFileLogger FileLogger { get; set; } = null!; + [Inject] private IFileSaveService FileSaveService { get; set; } = null!; + protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) { _loadGeneration++; + // Only signal cancel; the owning Refresh's using var disposes its CTS. + _loadCts?.Cancel(); + _stringFilterCts?.Cancel(); + _stringFilterCts?.Dispose(); + _stringFilterCts = null; } await base.DisposeAsyncCore(disposing); @@ -35,52 +78,244 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); } + private void ApplyIncrementalProjection() + { + if (_projectedThroughIndex >= _entries.Count) { return; } + + var levels = BuildLevelsForProjection(); + + var (newLines, newCount) = DebugLogProjection.ProjectRange( + _entries, + _projectedThroughIndex, + _entries.Count, + _levelOperator, + levels, + _activeStringFilter); + + if (newLines.Count > 0) { _displayed.AddRange(newLines); } + + _filteredEntryCount += newCount; + _projectedThroughIndex = _entries.Count; + } + + private void ApplyProjection() + { + var levels = BuildLevelsForProjection(); + var (lines, count) = DebugLogProjection.Project(_entries, _levelOperator, levels, _activeStringFilter); + + SetDisplayed(lines); + _filteredEntryCount = count; + _projectedThroughIndex = _entries.Count; + } + + private IReadOnlyList BuildLevelsForProjection() => + _levelOperator == FilterEvaluator.MultiSelect + ? _multiLevels + : _singleLevel.HasValue ? [_singleLevel.Value] : []; + private async Task Clear() { + try + { + await FileLogger.ClearAsync(); + } + catch (Exception ex) + { + await AlertDialogService.ShowAlert("Clear Failed", ex.Message, "OK"); + + return; + } + _loadGeneration++; - _data.Clear(); + _loadCts?.Cancel(); + _entries = []; + SetDisplayed([]); + _filteredEntryCount = 0; + _projectedThroughIndex = 0; _hasLoaded = true; - await FileLogger.ClearAsync(); - StateHasChanged(); } + private void FlushPendingStringFilter() + { + if (SyncPendingStringFilter()) + { + ApplyProjection(); + } + } + + private async Task HandleCopyAsync() + { + FlushPendingStringFilter(); + + if (_filteredEntryCount == 0) { return; } + + await ClipboardService.CopyTextAsync(string.Join(Environment.NewLine, _displayedView)); + } + + private async Task HandleExportAsync() + { + FlushPendingStringFilter(); + + if (_filteredEntryCount == 0) { return; } + + var suggestedFileName = $"debug-log-{DateTime.Now:yyyyMMdd-HHmmss}.log"; + var snapshot = _displayedView.ToArray(); + + try + { + await FileSaveService.SaveAsync(suggestedFileName, FileSaveServiceFileTypes.Log, async stream => + { + await using var writer = new StreamWriter(stream, leaveOpen: true); + + for (var i = 0; i < snapshot.Length; i++) + { + if (i > 0) { await writer.WriteAsync(Environment.NewLine); } + + await writer.WriteAsync(snapshot[i]); + } + }); + } + catch (Exception ex) + { + await AlertDialogService.ShowAlert("Export Failed", ex.Message, "OK"); + } + } + + private void HandleLevelOperatorChanged(FilterEvaluator op) + { + _levelOperator = op; + SyncPendingStringFilter(); + ApplyProjection(); + } + + private void HandleMultiLevelsChanged(List levels) + { + _multiLevels = levels; + SyncPendingStringFilter(); + ApplyProjection(); + } + + private void HandleSingleLevelChanged(LogLevel? level) + { + _singleLevel = level; + SyncPendingStringFilter(); + ApplyProjection(); + } + + private async Task HandleStringFilterInput(ChangeEventArgs args) + { + _pendingStringFilter = args.Value?.ToString() ?? string.Empty; + + var generation = ++_stringFilterGeneration; + + _stringFilterCts?.Cancel(); + _stringFilterCts?.Dispose(); + + var cts = new CancellationTokenSource(); + _stringFilterCts = cts; + + try + { + await Task.Delay(StringFilterDebounceMs, cts.Token); + + if (generation != _stringFilterGeneration) { return; } + + _activeStringFilter = _pendingStringFilter; + ApplyProjection(); + StateHasChanged(); + } + catch (TaskCanceledException) { } + } + private async Task Refresh() { + SyncPendingStringFilter(); + var generation = ++_loadGeneration; + // Cancel the prior load; its own using var will dispose its CTS. + _loadCts?.Cancel(); + using var loadCts = new CancellationTokenSource(); + _loadCts = loadCts; + _hasLoaded = false; - _data.Clear(); + var streamingEntries = new List(); + _entries = streamingEntries; + SetDisplayed([]); + _filteredEntryCount = 0; + _projectedThroughIndex = 0; StateHasChanged(); + var parser = new DebugLogEntryStreamParser(); var sinceRender = 0; + Exception? loadException = null; try { - await foreach (var line in FileLogger.LoadAsync()) + await foreach (var line in FileLogger.LoadAsync(loadCts.Token)) { if (generation != _loadGeneration) { return; } - _data.Add(line); + var emitted = parser.AddLine(line); + + if (emitted is not null) { streamingEntries.Add(emitted); } if (++sinceRender < RenderBatchSize) { continue; } sinceRender = 0; + ApplyIncrementalProjection(); StateHasChanged(); - await Task.Yield(); } } + catch (OperationCanceledException) when (loadCts.IsCancellationRequested || generation != _loadGeneration) { } + catch (Exception ex) + { + loadException = ex; + } finally { if (generation == _loadGeneration) { + var final = parser.Flush(); + + if (final is not null) { streamingEntries.Add(final); } + + ApplyIncrementalProjection(); _hasLoaded = true; StateHasChanged(); } + + if (ReferenceEquals(_loadCts, loadCts)) { _loadCts = null; } + } + + if (loadException is not null && generation == _loadGeneration) + { + await AlertDialogService.ShowAlert("Refresh Failed", loadException.Message, "OK"); } } + + private void SetDisplayed(List lines) + { + _displayed = lines; + _displayedView = new ReversedListView(_displayed); + } + + private bool SyncPendingStringFilter() + { + if (_activeStringFilter == _pendingStringFilter) { return false; } + + _stringFilterCts?.Cancel(); + _stringFilterCts?.Dispose(); + _stringFilterCts = null; + _stringFilterGeneration++; + + _activeStringFilter = _pendingStringFilter; + + return true; + } } diff --git a/src/EventLogExpert.Components/Modals/DebugLogModal.razor.css b/src/EventLogExpert.Components/Modals/DebugLogModal.razor.css new file mode 100644 index 00000000..9ab12c70 --- /dev/null +++ b/src/EventLogExpert.Components/Modals/DebugLogModal.razor.css @@ -0,0 +1,65 @@ +.debug-log-viewport { + box-sizing: border-box; + width: 100%; + min-width: 0; + flex: 1 1 auto; + min-height: 0; + overflow-x: auto; + overflow-y: auto; +} + +.debug-log-row { + box-sizing: border-box; + height: 18px; + padding: 0 0.25rem; + + font-family: monospace; + line-height: 18px; + white-space: pre; +} + +.debug-log-filter-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0 0 0.5rem 0; + margin-bottom: 0.5rem; +} + +.debug-log-text-filter { + flex: 1 1 auto; + min-width: 12rem; +} + +.debug-log-filter-bar ::deep .debug-log-level-multi { + min-width: 18rem; +} + +.debug-log-footer { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.5rem; + width: 100%; +} + +.debug-log-footer-actions { + display: flex; + gap: 0.5rem; +} + +.debug-log-footer-left { + justify-self: start; +} + +.debug-log-footer-right { + justify-self: end; +} + +.debug-log-footer-counter { + justify-self: center; + + font-size: 0.85rem; + opacity: 0.85; +} diff --git a/src/EventLogExpert.Components/_Imports.razor b/src/EventLogExpert.Components/_Imports.razor index cd31af72..5ab39b3e 100644 --- a/src/EventLogExpert.Components/_Imports.razor +++ b/src/EventLogExpert.Components/_Imports.razor @@ -1,5 +1,6 @@ @using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization @using EventLogExpert.Components.Base @using EventLogExpert.Components.Database @using EventLogExpert.Components.Filters diff --git a/src/EventLogExpert.UI.Tests/Services/DebugLogEntryParserTests.cs b/src/EventLogExpert.UI.Tests/Services/DebugLogEntryParserTests.cs new file mode 100644 index 00000000..703cd782 --- /dev/null +++ b/src/EventLogExpert.UI.Tests/Services/DebugLogEntryParserTests.cs @@ -0,0 +1,572 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Services; +using EventLogExpert.UI.Tests.TestUtils.Constants; +using Microsoft.Extensions.Logging; +using System.Globalization; + +namespace EventLogExpert.UI.Tests.Services; + +public sealed class DebugLogEntryParserTests +{ + [Fact] + public void Parse_WhenContinuationHasBareDateHeaderShape_ShouldNotSplitFromPriorEntry() + { + // Arrange + var firstLine = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Information), + Constants.DebugLogFirstMessage); + var continuationLine = $"[2026-04-29] [12] [{nameof(LogLevel.Error)}] payload that mentions a bracketed date"; + + // Act + var entries = DebugLogEntryParser.Parse([firstLine, continuationLine]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal($"{Constants.DebugLogFirstMessage}\n{continuationLine}", entry.Message); + Assert.Equal($"{firstLine}\n{continuationLine}", entry.RawLine); + } + + [Fact] + public void Parse_WhenContinuationLineFollowsEntry_ShouldFoldIntoPrevious() + { + // Arrange + var firstLine = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Error), + Constants.DebugLogFirstMessage); + var continuationLine = " at SomeMethod() in SomeFile.cs:line 42"; + + // Act + var entries = DebugLogEntryParser.Parse([firstLine, continuationLine]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Equal($"{Constants.DebugLogFirstMessage}\n{continuationLine}", entry.Message); + Assert.Equal($"{firstLine}\n{continuationLine}", entry.RawLine); + } + + [Fact] + public void Parse_WhenContinuationLineHasNoPriorEntry_ShouldYieldStandaloneEntry() + { + // Arrange + var orphan = "stray line with no prefix"; + + // Act + var entries = DebugLogEntryParser.Parse([orphan]); + + // Assert + var entry = Assert.Single(entries); + Assert.Null(entry.Timestamp); + Assert.Null(entry.ThreadId); + Assert.Null(entry.Level); + Assert.Equal(orphan, entry.Message); + Assert.Equal(orphan, entry.RawLine); + } + + [Fact] + public void Parse_WhenEmptyInput_ShouldReturnEmpty() + { + // Act + var entries = DebugLogEntryParser.Parse([]); + + // Assert + Assert.Empty(entries); + } + + [Fact] + public void Parse_WhenLevelNameLowerCase_ShouldStillRecognize() + { + // Arrange + var line = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + "warning", + Constants.DebugLogTestMessage); + + // Act + var entries = DebugLogEntryParser.Parse([line]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal(LogLevel.Warning, entry.Level); + } + + [Fact] + public void Parse_WhenLevelNameUnknown_ShouldTreatAsContinuation() + { + // Arrange + var firstLine = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Information), + Constants.DebugLogFirstMessage); + var malformed = $"[{Constants.DebugLogTestTimestamp}] [{Constants.DebugLogTestThreadId}] [Bogus] payload"; + + // Act + var entries = DebugLogEntryParser.Parse([firstLine, malformed]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal($"{Constants.DebugLogFirstMessage}\n{malformed}", entry.Message); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + [InlineData(LogLevel.Warning)] + [InlineData(LogLevel.Error)] + [InlineData(LogLevel.Critical)] + public void Parse_WhenLogLevelName_ShouldRecognize(LogLevel level) + { + // Arrange + var line = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + level.ToString(), + Constants.DebugLogTestMessage); + + // Act + var entries = DebugLogEntryParser.Parse([line]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal(level, entry.Level); + } + + [Fact] + public void Parse_WhenMessageBodyEmpty_ShouldStillParseEntry() + { + // Arrange + var line = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Information), + string.Empty); + + // Act + var entries = DebugLogEntryParser.Parse([line]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal(string.Empty, entry.Message); + Assert.Equal(line, entry.RawLine); + } + + [Fact] + public void Parse_WhenMessageContainsBracketTokens_ShouldKeepThemInMessage() + { + // Arrange + var line = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Warning), + "[Custom] tag prefix and [Information] later"); + + // Act + var entries = DebugLogEntryParser.Parse([line]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal("[Custom] tag prefix and [Information] later", entry.Message); + } + + [Fact] + public void Parse_WhenMultipleContinuationLines_ShouldFoldAllIntoPrevious() + { + // Arrange + var firstLine = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Critical), + "Unhandled exception:"); + string[] stackTrace = + [ + "System.InvalidOperationException: Bad state", + " at A.B.C() in File.cs:line 1", + " at D.E.F() in File.cs:line 2" + ]; + + // Act + var entries = DebugLogEntryParser.Parse([firstLine, .. stackTrace]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal(LogLevel.Critical, entry.Level); + Assert.Equal($"Unhandled exception:\n{string.Join('\n', stackTrace)}", entry.Message); + Assert.Equal($"{firstLine}\n{string.Join('\n', stackTrace)}", entry.RawLine); + } + + [Fact] + public void Parse_WhenMultipleEntries_ShouldReturnInOrder() + { + // Arrange + string[] lines = + [ + BuildLine(Constants.DebugLogTestTimestamp, Constants.DebugLogTestThreadId, nameof(LogLevel.Trace), Constants.DebugLogFirstMessage), + BuildLine(Constants.DebugLogTestTimestamp, Constants.DebugLogTestThreadId, nameof(LogLevel.Information), Constants.DebugLogSecondMessage), + BuildLine(Constants.DebugLogTestTimestamp, Constants.DebugLogTestThreadId, nameof(LogLevel.Error), Constants.DebugLogThirdMessage) + ]; + + // Act + var entries = DebugLogEntryParser.Parse(lines); + + // Assert + Assert.Equal(3, entries.Count); + Assert.Equal(LogLevel.Trace, entries[0].Level); + Assert.Equal(Constants.DebugLogFirstMessage, entries[0].Message); + Assert.Equal(LogLevel.Information, entries[1].Level); + Assert.Equal(Constants.DebugLogSecondMessage, entries[1].Message); + Assert.Equal(LogLevel.Error, entries[2].Level); + Assert.Equal(Constants.DebugLogThirdMessage, entries[2].Message); + } + + [Fact] + public void Parse_WhenMultipleEntriesEachWithContinuations_ShouldFoldContinuationsIntoTheirRespectiveEntry() + { + // Arrange + var firstStart = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Error), + Constants.DebugLogFirstMessage); + var firstContinuation = " at A.B.C() in File.cs:line 1"; + + var secondStart = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Warning), + Constants.DebugLogSecondMessage); + var secondContinuationOne = " at D.E.F() in File.cs:line 2"; + var secondContinuationTwo = " at G.H.I() in File.cs:line 3"; + + // Act + var entries = DebugLogEntryParser.Parse( + [firstStart, firstContinuation, secondStart, secondContinuationOne, secondContinuationTwo]); + + // Assert + Assert.Equal(2, entries.Count); + + Assert.Equal(LogLevel.Error, entries[0].Level); + Assert.Equal($"{Constants.DebugLogFirstMessage}\n{firstContinuation}", entries[0].Message); + Assert.Equal($"{firstStart}\n{firstContinuation}", entries[0].RawLine); + + Assert.Equal(LogLevel.Warning, entries[1].Level); + Assert.Equal( + $"{Constants.DebugLogSecondMessage}\n{secondContinuationOne}\n{secondContinuationTwo}", + entries[1].Message); + Assert.Equal( + $"{secondStart}\n{secondContinuationOne}\n{secondContinuationTwo}", + entries[1].RawLine); + } + + [Fact] + public void Parse_WhenOrphanFollowedByContinuationLines_ShouldFoldContinuationsIntoOrphan() + { + // Arrange + var orphanFirstLine = "stray prelude with no prefix"; + var continuationOne = "more stray content"; + var continuationTwo = "and another stray line"; + + // Act + var entries = DebugLogEntryParser.Parse([orphanFirstLine, continuationOne, continuationTwo]); + + // Assert + var entry = Assert.Single(entries); + Assert.Null(entry.Timestamp); + Assert.Null(entry.ThreadId); + Assert.Null(entry.Level); + Assert.Equal($"{orphanFirstLine}\n{continuationOne}\n{continuationTwo}", entry.Message); + Assert.Equal($"{orphanFirstLine}\n{continuationOne}\n{continuationTwo}", entry.RawLine); + } + + [Fact] + public void Parse_WhenStandardLine_ShouldReturnEntryWithAllFields() + { + // Arrange + var line = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Information), + Constants.DebugLogTestMessage); + + // Act + var entries = DebugLogEntryParser.Parse([line]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal( + DateTimeOffset.Parse(Constants.DebugLogTestTimestamp, CultureInfo.InvariantCulture), + entry.Timestamp); + Assert.Equal(Constants.DebugLogTestThreadId, entry.ThreadId); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal(Constants.DebugLogTestMessage, entry.Message); + Assert.Equal(line, entry.RawLine); + } + + [Fact] + public void Parse_WhenThreadIdMalformed_ShouldTreatAsContinuation() + { + // Arrange + var firstLine = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Information), + Constants.DebugLogFirstMessage); + var malformed = $"[{Constants.DebugLogTestTimestamp}] [abc] [Information] payload"; + + // Act + var entries = DebugLogEntryParser.Parse([firstLine, malformed]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal($"{Constants.DebugLogFirstMessage}\n{malformed}", entry.Message); + } + + [Fact] + public void Parse_WhenTimestampMalformed_ShouldTreatAsContinuation() + { + // Arrange + var firstLine = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Information), + Constants.DebugLogFirstMessage); + var malformed = "[not-a-timestamp] [12] [Information] payload"; + + // Act + var entries = DebugLogEntryParser.Parse([firstLine, malformed]); + + // Assert + var entry = Assert.Single(entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal($"{Constants.DebugLogFirstMessage}\n{malformed}", entry.Message); + } + + [Fact] + public void TryParseLine_WhenNoPrefix_ShouldReturnFalse() + { + // Act + var success = DebugLogEntryParser.TryParseLine("orphan line", out var entry); + + // Assert + Assert.False(success); + Assert.Null(entry); + } + + [Fact] + public void TryParseLine_WhenValidPrefix_ShouldReturnTrueAndEntry() + { + // Arrange + var line = BuildLine( + Constants.DebugLogTestTimestamp, + Constants.DebugLogTestThreadId, + nameof(LogLevel.Information), + Constants.DebugLogTestMessage); + + // Act + var success = DebugLogEntryParser.TryParseLine(line, out var entry); + + // Assert + Assert.True(success); + Assert.NotNull(entry); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal(Constants.DebugLogTestMessage, entry.Message); + Assert.Equal(line, entry.RawLine); + } + + private static string BuildLine(string timestamp, int threadId, string level, string message) => + $"[{timestamp}] [{threadId}] [{level}] {message}"; +} + +public sealed class DebugLogEntryStreamParserTests +{ + [Fact] + public void AddLine_AfterFlush_ShouldStartFreshState() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + parser.AddLine(BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage)); + parser.Flush(); + + // Act + var emitted = parser.AddLine(BuildLine(LogLevel.Warning, Constants.DebugLogSecondMessage)); + + // Assert + Assert.Null(emitted); + + var final = parser.Flush(); + + Assert.NotNull(final); + Assert.Equal(LogLevel.Warning, final.Level); + Assert.Equal(Constants.DebugLogSecondMessage, final.Message); + } + + [Fact] + public void AddLine_WhenContinuationLineHasNoPending_ShouldStartStandaloneEntry() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + const string Orphan = "stray line with no prefix"; + + // Act + var emitted = parser.AddLine(Orphan); + + // Assert + Assert.Null(emitted); + + var final = parser.Flush(); + + Assert.NotNull(final); + Assert.Null(final.Level); + Assert.Null(final.Timestamp); + Assert.Null(final.ThreadId); + Assert.Equal(Orphan, final.Message); + Assert.Equal(Orphan, final.RawLine); + } + + [Fact] + public void AddLine_WhenContinuationLinesPrecedeNextHeader_ShouldFoldIntoEmittedEntry() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + var firstLine = BuildLine(LogLevel.Error, Constants.DebugLogFirstMessage); + const string Stack1 = " at System.Foo.Bar()"; + const string Stack2 = " at System.Baz.Qux()"; + var secondLine = BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage); + + // Act + parser.AddLine(firstLine); + Assert.Null(parser.AddLine(Stack1)); + Assert.Null(parser.AddLine(Stack2)); + var emitted = parser.AddLine(secondLine); + + // Assert + Assert.NotNull(emitted); + Assert.Equal($"{Constants.DebugLogFirstMessage}\n{Stack1}\n{Stack2}", emitted.Message); + Assert.Equal($"{firstLine}\n{Stack1}\n{Stack2}", emitted.RawLine); + } + + [Fact] + public void AddLine_WhenFirstLineIsHeader_ShouldBufferAndReturnNull() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + var line = BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage); + + // Act + var emitted = parser.AddLine(line); + + // Assert + Assert.Null(emitted); + } + + [Fact] + public void AddLine_WhenNullLine_ShouldThrow() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + + // Act + Assert + Assert.Throws(() => parser.AddLine(null!)); + } + + [Fact] + public void AddLine_WhenSecondHeaderArrives_ShouldEmitFirstEntry() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + var firstLine = BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage); + var secondLine = BuildLine(LogLevel.Warning, Constants.DebugLogSecondMessage); + + // Act + parser.AddLine(firstLine); + var emitted = parser.AddLine(secondLine); + + // Assert + Assert.NotNull(emitted); + Assert.Equal(LogLevel.Information, emitted.Level); + Assert.Equal(Constants.DebugLogFirstMessage, emitted.Message); + Assert.Equal(firstLine, emitted.RawLine); + } + + [Fact] + public void AddLine_WhenSubsequentHeaderHasOlderTimestamp_ShouldStartNewEntry() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + var pendingLine = BuildLine(Constants.DebugLogTestTimestamp, LogLevel.Error, Constants.DebugLogFirstMessage); + var olderHeaderLine = BuildLine(Constants.DebugLogOlderTimestamp, LogLevel.Information, Constants.DebugLogSecondMessage); + + // Act + parser.AddLine(pendingLine); + var emittedOnSecondLine = parser.AddLine(olderHeaderLine); + var final = parser.Flush(); + + // Assert + Assert.NotNull(emittedOnSecondLine); + Assert.Equal(LogLevel.Error, emittedOnSecondLine.Level); + Assert.Equal(Constants.DebugLogFirstMessage, emittedOnSecondLine.Message); + Assert.Equal(pendingLine, emittedOnSecondLine.RawLine); + Assert.NotNull(final); + Assert.Equal(LogLevel.Information, final.Level); + Assert.Equal(Constants.DebugLogSecondMessage, final.Message); + Assert.Equal(olderHeaderLine, final.RawLine); + } + + [Fact] + public void Flush_WhenCalledTwice_SecondReturnsNull() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + parser.AddLine(BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage)); + + // Act + Assert + Assert.NotNull(parser.Flush()); + Assert.Null(parser.Flush()); + } + + [Fact] + public void Flush_WhenNoLinesAdded_ShouldReturnNull() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + + // Act + Assert + Assert.Null(parser.Flush()); + } + + [Fact] + public void Flush_WhenPendingHeaderHasContinuations_ShouldFoldThemAndReturnEntry() + { + // Arrange + var parser = new DebugLogEntryStreamParser(); + var headerLine = BuildLine(LogLevel.Error, Constants.DebugLogFirstMessage); + const string Stack = " at System.Foo.Bar()"; + + // Act + parser.AddLine(headerLine); + parser.AddLine(Stack); + var final = parser.Flush(); + + // Assert + Assert.NotNull(final); + Assert.Equal($"{Constants.DebugLogFirstMessage}\n{Stack}", final.Message); + Assert.Equal($"{headerLine}\n{Stack}", final.RawLine); + } + + private static string BuildLine(LogLevel level, string message) => + $"[{Constants.DebugLogTestTimestamp}] [{Constants.DebugLogTestThreadId}] [{level}] {message}"; + + private static string BuildLine(string timestamp, LogLevel level, string message) => + $"[{timestamp}] [{Constants.DebugLogTestThreadId}] [{level}] {message}"; +} diff --git a/src/EventLogExpert.UI.Tests/Services/DebugLogProjectionTests.cs b/src/EventLogExpert.UI.Tests/Services/DebugLogProjectionTests.cs new file mode 100644 index 00000000..e36778b8 --- /dev/null +++ b/src/EventLogExpert.UI.Tests/Services/DebugLogProjectionTests.cs @@ -0,0 +1,460 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Models; +using EventLogExpert.UI.Services; +using EventLogExpert.UI.Tests.TestUtils.Constants; +using Microsoft.Extensions.Logging; + +namespace EventLogExpert.UI.Tests.Services; + +public sealed class DebugLogProjectionTests +{ + [Fact] + public void Project_WhenEntriesNull_ShouldThrow() + { + // Act + Assert + Assert.Throws(() => + DebugLogProjection.Project(null!, FilterEvaluator.Equals, [], string.Empty)); + } + + [Fact] + public void Project_WhenInputEmpty_ShouldReturnEmpty() + { + // Act + var (lines, count) = DebugLogProjection.Project([], FilterEvaluator.Equals, [], string.Empty); + + // Assert + Assert.Empty(lines); + Assert.Equal(0, count); + } + + [Fact] + public void Project_WhenLevelAndTextFilterCombined_ShouldRequireBoth() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, "match foo"), + BuildEntry(LogLevel.Warning, "match foo"), + BuildEntry(LogLevel.Information, "no match"), + }; + + // Act + var (view, count) = ProjectView( + entries, + FilterEvaluator.Equals, + [LogLevel.Information], + "foo"); + + // Assert + Assert.Equal(1, count); + var only = Assert.Single(view); + Assert.Contains("match foo", only); + } + + [Fact] + public void Project_WhenLevelEquals_ShouldKeepOnlyMatchingLevel() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + BuildEntry(LogLevel.Information, Constants.DebugLogThirdMessage), + }; + + // Act + var (view, count) = ProjectView( + entries, + FilterEvaluator.Equals, + [LogLevel.Information], + string.Empty); + + // Assert + Assert.Equal(2, count); + Assert.Equal(2, view.Count); + Assert.Contains(Constants.DebugLogThirdMessage, view[0]); + Assert.Contains(Constants.DebugLogFirstMessage, view[1]); + } + + [Fact] + public void Project_WhenLevelFilterActiveAndEntryHasNullLevel_ShouldExcludeEntry() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + new DebugLogEntry(null, null, null, 0, "orphan"), + }; + + // Act + var (_, count) = DebugLogProjection.Project( + entries, + FilterEvaluator.Equals, + [LogLevel.Information], + string.Empty); + + // Assert + Assert.Equal(1, count); + } + + [Fact] + public void Project_WhenLevelMultiSelect_ShouldKeepAnyListedLevel() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + BuildEntry(LogLevel.Error, Constants.DebugLogThirdMessage), + BuildEntry(LogLevel.Critical, Constants.DebugLogNewMessage), + }; + + // Act + var (view, count) = ProjectView( + entries, + FilterEvaluator.MultiSelect, + [LogLevel.Error, LogLevel.Critical], + string.Empty); + + // Assert + Assert.Equal(2, count); + Assert.Equal(2, view.Count); + Assert.Contains(Constants.DebugLogNewMessage, view[0]); + Assert.Contains(Constants.DebugLogThirdMessage, view[1]); + } + + [Fact] + public void Project_WhenLevelMultiSelectAndEntryHasNullLevel_ShouldExcludeEntry() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + new DebugLogEntry(null, null, null, 0, "orphan"), + }; + + // Act + var (_, count) = DebugLogProjection.Project( + entries, + FilterEvaluator.MultiSelect, + [LogLevel.Information, LogLevel.Warning], + string.Empty); + + // Assert + Assert.Equal(1, count); + } + + [Fact] + public void Project_WhenLevelNotEqual_ShouldExcludeMatchingLevel() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + BuildEntry(LogLevel.Information, Constants.DebugLogThirdMessage), + }; + + // Act + var (lines, count) = DebugLogProjection.Project( + entries, + FilterEvaluator.NotEqual, + [LogLevel.Information], + string.Empty); + + // Assert + Assert.Equal(1, count); + var only = Assert.Single(lines); + Assert.Contains(Constants.DebugLogSecondMessage, only); + } + + [Fact] + public void Project_WhenLevelNotEqualAndEntryHasNullLevel_ShouldIncludeEntry() + { + // Arrange — null Level is "not equal" to any specific level, so NotEqual must include it. + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + new DebugLogEntry(null, null, null, 0, "orphan"), + }; + + // Act + var (lines, count) = DebugLogProjection.Project( + entries, + FilterEvaluator.NotEqual, + [LogLevel.Information], + string.Empty); + + // Assert + Assert.Equal(1, count); + var only = Assert.Single(lines); + Assert.Equal("orphan", only); + } + + [Fact] + public void Project_WhenLevelsEmpty_ShouldIgnoreLevelOperator() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + }; + + // Act + var (_, count) = DebugLogProjection.Project(entries, FilterEvaluator.NotEqual, [], string.Empty); + + // Assert + Assert.Equal(2, count); + } + + [Fact] + public void Project_WhenLevelsNull_ShouldThrow() + { + // Act + Assert + Assert.Throws(() => + DebugLogProjection.Project([], FilterEvaluator.Equals, null!, string.Empty)); + } + + [Fact] + public void Project_WhenMultiLineEntryHasBlankPhysicalLine_ShouldEmitEmptyStringForBlank() + { + // Arrange + var rawLine = + $"[{Constants.DebugLogTestTimestamp}] [{Constants.DebugLogTestThreadId}] [Error] outer\n\nat MoreFrames"; + + var message = "outer\n\nat MoreFrames"; + + var entry = new DebugLogEntry( + DateTimeOffset.Parse(Constants.DebugLogTestTimestamp), + Constants.DebugLogTestThreadId, + LogLevel.Error, + rawLine.Length - message.Length, + rawLine); + + // Act + var (view, count) = ProjectView([entry], FilterEvaluator.Equals, [], string.Empty); + + // Assert + Assert.Equal(1, count); + Assert.Equal(3, view.Count); + Assert.Contains("outer", view[0]); + Assert.Equal(string.Empty, view[1]); + Assert.Equal("at MoreFrames", view[2]); + } + + [Fact] + public void Project_WhenMultiLineEntryMatchesViaContinuation_ShouldEmitAllPhysicalLines() + { + // Arrange + var rawLine = + $"[{Constants.DebugLogTestTimestamp}] [{Constants.DebugLogTestThreadId}] [Error] outer\nstack-trace-foo\nat MoreFrames"; + + var message = "outer\nstack-trace-foo\nat MoreFrames"; + + var entry = new DebugLogEntry( + DateTimeOffset.Parse(Constants.DebugLogTestTimestamp), + Constants.DebugLogTestThreadId, + LogLevel.Error, + rawLine.Length - message.Length, + rawLine); + + // Act + var (view, count) = ProjectView([entry], FilterEvaluator.Equals, [], "foo"); + + // Assert + Assert.Equal(1, count); + Assert.Equal(3, view.Count); + Assert.Contains("outer", view[0]); + Assert.Equal("stack-trace-foo", view[1]); + Assert.Equal("at MoreFrames", view[2]); + } + + [Fact] + public void Project_WhenNoFilters_ShouldReturnAllLinesInDisplayOrderViaReversedView() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + BuildEntry(LogLevel.Error, Constants.DebugLogThirdMessage), + }; + + // Act + var (view, count) = ProjectView(entries, FilterEvaluator.Equals, [], string.Empty); + + // Assert + Assert.Equal(3, count); + Assert.Equal(3, view.Count); + Assert.Contains(Constants.DebugLogThirdMessage, view[0]); + Assert.Contains(Constants.DebugLogSecondMessage, view[1]); + Assert.Contains(Constants.DebugLogFirstMessage, view[2]); + } + + [Fact] + public void Project_WhenTextFilterDifferentCase_ShouldMatchCaseInsensitively() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, "Foo Bar"), + }; + + // Act + var (_, count) = DebugLogProjection.Project(entries, FilterEvaluator.Equals, [], "FOO"); + + // Assert + Assert.Equal(1, count); + } + + [Fact] + public void Project_WhenTextFilterMatches_ShouldKeepOnlyContainingEntries() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, "alpha foo bravo"), + BuildEntry(LogLevel.Information, "charlie delta"), + BuildEntry(LogLevel.Warning, "echo foo foxtrot"), + }; + + // Act + var (view, count) = ProjectView(entries, FilterEvaluator.Equals, [], "foo"); + + // Assert + Assert.Equal(2, count); + Assert.Equal(2, view.Count); + Assert.Contains("echo foo foxtrot", view[0]); + Assert.Contains("alpha foo bravo", view[1]); + } + + [Fact] + public void ProjectRange_WhenFilterApplied_ShouldEvaluateAgainstSliceOnly() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + BuildEntry(LogLevel.Information, Constants.DebugLogThirdMessage), + }; + + // Act + var (lines, count) = DebugLogProjection.ProjectRange( + entries, + startIndex: 1, + endIndex: 3, + FilterEvaluator.Equals, + [LogLevel.Information], + string.Empty); + + // Assert + Assert.Equal(1, count); + var only = Assert.Single(lines); + Assert.Contains(Constants.DebugLogThirdMessage, only); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, 4)] + [InlineData(2, 1)] + public void ProjectRange_WhenIndicesOutOfRange_ShouldThrow(int startIndex, int endIndex) + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + BuildEntry(LogLevel.Information, Constants.DebugLogThirdMessage), + }; + + // Act + Assert + Assert.Throws(() => + DebugLogProjection.ProjectRange( + entries, + startIndex, + endIndex, + FilterEvaluator.Equals, + [], + string.Empty)); + } + + [Fact] + public void ProjectRange_WhenSliceIsEmpty_ShouldReturnEmpty() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + }; + + // Act + var (lines, count) = DebugLogProjection.ProjectRange( + entries, + startIndex: 2, + endIndex: 2, + FilterEvaluator.Equals, + [], + string.Empty); + + // Assert + Assert.Empty(lines); + Assert.Equal(0, count); + } + + [Fact] + public void ProjectRange_WhenSliceIsTrailingThird_ShouldReturnOnlyThatSliceInDisplayOrderViaReversedView() + { + // Arrange + var entries = new[] + { + BuildEntry(LogLevel.Information, Constants.DebugLogFirstMessage), + BuildEntry(LogLevel.Warning, Constants.DebugLogSecondMessage), + BuildEntry(LogLevel.Error, Constants.DebugLogThirdMessage), + }; + + // Act + var (lines, count) = DebugLogProjection.ProjectRange( + entries, + startIndex: 1, + endIndex: 3, + FilterEvaluator.Equals, + [], + string.Empty); + + var view = new ReversedListView(lines); + + // Assert + Assert.Equal(2, count); + Assert.Equal(2, view.Count); + Assert.Contains(Constants.DebugLogThirdMessage, view[0]); + Assert.Contains(Constants.DebugLogSecondMessage, view[1]); + } + + private static DebugLogEntry BuildEntry(LogLevel level, string message) + { + var rawLine = $"[{Constants.DebugLogTestTimestamp}] [{Constants.DebugLogTestThreadId}] [{level}] {message}"; + + return new DebugLogEntry( + DateTimeOffset.Parse(Constants.DebugLogTestTimestamp), + Constants.DebugLogTestThreadId, + level, + rawLine.Length - message.Length, + rawLine); + } + + private static (ReversedListView View, int Count) ProjectView( + IReadOnlyList entries, + FilterEvaluator levelOperator, + IReadOnlyList levels, + string? textFilter) + { + var (lines, count) = DebugLogProjection.Project(entries, levelOperator, levels, textFilter); + + return (new ReversedListView(lines), count); + } +} diff --git a/src/EventLogExpert.UI.Tests/Services/DebugLogServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DebugLogServiceTests.cs index 05ed3ba1..19c29947 100644 --- a/src/EventLogExpert.UI.Tests/Services/DebugLogServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DebugLogServiceTests.cs @@ -61,6 +61,37 @@ public async Task ClearAsync_WhenLogFileDoesNotExist_ShouldCreateEmptyFile() Assert.Empty(content); } + [Fact] + public async Task ClearAsync_WhenSecondInstanceHoldsWriter_ShouldNotThrowFileLockException() + { + // Arrange + var fileLocationOptions = new FileLocationOptions(_testDirectory); + var mockSettingsService = CreateMockSettingsService(LogLevel.Information); + + using var firstInstance = new DebugLogService(fileLocationOptions, mockSettingsService); + firstInstance.Info($"first instance line"); + + using var secondInstance = new DebugLogService(fileLocationOptions, mockSettingsService); + secondInstance.Info($"second instance line"); + + // Act + Assert - clearing from one instance must not fail because the other holds the writer. + var exception = await Record.ExceptionAsync(() => firstInstance.ClearAsync()); + Assert.Null(exception); + + // Both instances must write cleanly post-clear; no NUL padding from stale positions. + secondInstance.Info($"after clear from second"); + firstInstance.Info($"after clear from first"); + + var bytes = ReadLogFileBytes(); + Assert.DoesNotContain((byte)0, bytes); + + var content = ReadLogFile(); + var lines = content.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + Assert.Contains("after clear from second", lines[0]); + Assert.Contains("after clear from first", lines[1]); + } + [Fact] public void Constructor_ShouldCreateServiceSuccessfully() { @@ -191,7 +222,8 @@ public async Task LoadAsync_WhenLogFileDeletedDuringRead_ShouldAllowDeletion() // Act - Start enumeration and delete the file while the reader holds a handle await using var enumerator = - debugLogService.LoadAsync().GetAsyncEnumerator(TestContext.Current.CancellationToken); + debugLogService.LoadAsync(TestContext.Current.CancellationToken) + .GetAsyncEnumerator(TestContext.Current.CancellationToken); // Read first line to ensure the file is open Assert.True(await enumerator.MoveNextAsync()); @@ -229,7 +261,8 @@ public async Task LoadAsync_WhenLogFileDoesNotExist_ShouldReturnNoLines() using var debugLogService = new DebugLogService(fileLocationOptions, mockSettingsService); // Act - var lines = await debugLogService.LoadAsync().ToListAsync(TestContext.Current.CancellationToken); + var lines = await debugLogService.LoadAsync(TestContext.Current.CancellationToken) + .ToListAsync(TestContext.Current.CancellationToken); // Assert Assert.Empty(lines); @@ -248,7 +281,8 @@ public async Task LoadAsync_WhenLogFileHasContent_ShouldReturnAllLines() using var debugLogService = new DebugLogService(fileLocationOptions, mockSettingsService); // Act - var lines = await debugLogService.LoadAsync().ToListAsync(TestContext.Current.CancellationToken); + var lines = await debugLogService.LoadAsync(TestContext.Current.CancellationToken) + .ToListAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal(3, lines.Count); @@ -269,7 +303,8 @@ public async Task LoadAsync_WhenLogFileIsEmpty_ShouldReturnNoLines() using var debugLogService = new DebugLogService(fileLocationOptions, mockSettingsService); // Act - var lines = await debugLogService.LoadAsync().ToListAsync(TestContext.Current.CancellationToken); + var lines = await debugLogService.LoadAsync(TestContext.Current.CancellationToken) + .ToListAsync(TestContext.Current.CancellationToken); // Assert Assert.Empty(lines); @@ -331,6 +366,80 @@ public void Trace_ShouldIncludeTimestamp() Assert.Matches(@"\[\d{4}-\d{2}-\d{2}", content); } + [Fact] + public async Task Trace_WhenCalledFromMultipleThreadsConcurrently_ShouldProduceOrderedAndCompleteOutput() + { + // Arrange — exercises the writer's synchronization boundary: timestamp capture, prefix formatting, + // and the underlying StreamWriter call all happen INSIDE _writeLock.EnterScope(). A regression that + // moves any of those steps back outside the lock will surface as either (a) interleaved/garbled + // lines that fail to parse, (b) timestamps that go backwards within the file, or (c) lost or + // duplicated payloads. + var fileLocationOptions = new FileLocationOptions(_testDirectory); + var mockSettingsService = CreateMockSettingsService(LogLevel.Information); + const int WriterCount = 64; + const int WritesPerWriter = 4; + const int TotalWrites = WriterCount * WritesPerWriter; + var startGate = new TaskCompletionSource(); + + // Act + using (var debugLogService = new DebugLogService(fileLocationOptions, mockSettingsService)) + { + var writers = Enumerable.Range(0, WriterCount) + .Select(writerIndex => Task.Run(async () => + { + await startGate.Task; + + for (var writeIndex = 0; writeIndex < WritesPerWriter; writeIndex++) + { + debugLogService.Info($"writer-{writerIndex}-msg-{writeIndex}"); + } + }, TestContext.Current.CancellationToken)) + .ToArray(); + + startGate.SetResult(); + await Task.WhenAll(writers); + } + + // Assert + var lines = await File.ReadAllLinesAsync(_testLogPath, TestContext.Current.CancellationToken); + + Assert.Equal(TotalWrites, lines.Length); + + var timestamps = new List(lines.Length); + var payloads = new List(lines.Length); + + foreach (var line in lines) + { + // Use the production parser directly so the test enforces the actual producer/consumer + // contract — if the prefix shape changes in DebugLogService.WriteTrace, the parser's + // regex changes alongside it, and this assertion follows automatically. + Assert.True( + DebugLogEntryParser.TryParseLine(line, out var entry), + $"Line did not parse via DebugLogEntryParser: {line}"); + + timestamps.Add(entry.Timestamp!.Value); + payloads.Add(entry.Message); + } + + for (var i = 1; i < timestamps.Count; i++) + { + // <= because DateTime.Now resolution can produce equal timestamps for closely-spaced + // captures inside the lock; what we forbid is a regression (later timestamp earlier in file). + Assert.True( + timestamps[i] >= timestamps[i - 1], + $"Timestamp regressed at line {i}: {timestamps[i - 1]:o} -> {timestamps[i]:o}"); + } + + var expectedPayloads = Enumerable.Range(0, WriterCount) + .SelectMany(writerIndex => Enumerable.Range(0, WritesPerWriter) + .Select(writeIndex => $"writer-{writerIndex}-msg-{writeIndex}")) + .ToHashSet(); + + // Equality on HashSet asserts both "no missing payloads" and "no duplicated/extra payloads". + Assert.Equal(expectedPayloads, payloads.ToHashSet()); + Assert.Equal(TotalWrites, payloads.Count); + } + [Fact] public void Trace_WhenCalledMultipleTimes_ShouldAppendToLogFile() { @@ -505,6 +614,23 @@ public void TraceIfEnabled_WhenLogLevelChangedAtRuntime_ShouldRespectNewLevel() Assert.Contains("warning message after change", content); } + [Fact] + public void WriteTrace_WhenSecondInstanceWritesToSameFile_ShouldNotThrowFileLockException() + { + // Arrange + var fileLocationOptions = new FileLocationOptions(_testDirectory); + var mockSettingsService = CreateMockSettingsService(LogLevel.Information); + + using var firstInstance = new DebugLogService(fileLocationOptions, mockSettingsService); + firstInstance.Info($"first instance line"); + + using var secondInstance = new DebugLogService(fileLocationOptions, mockSettingsService); + + // Act + Assert + var exception = Record.Exception(() => secondInstance.Info($"second instance line")); + Assert.Null(exception); + } + private static ISettingsService CreateMockSettingsService(LogLevel logLevel) { var mockSettingsService = Substitute.For(); @@ -556,4 +682,18 @@ private string ReadLogFile() return reader.ReadToEnd(); } + + private byte[] ReadLogFileBytes() + { + using var stream = new FileStream( + _testLogPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + + using var ms = new MemoryStream(); + stream.CopyTo(ms); + + return ms.ToArray(); + } } diff --git a/src/EventLogExpert.UI.Tests/Services/ReversedListViewTests.cs b/src/EventLogExpert.UI.Tests/Services/ReversedListViewTests.cs new file mode 100644 index 00000000..1cd02e33 --- /dev/null +++ b/src/EventLogExpert.UI.Tests/Services/ReversedListViewTests.cs @@ -0,0 +1,207 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Services; + +namespace EventLogExpert.UI.Tests.Services; + +public sealed class ReversedListViewTests +{ + [Fact] + public void Add_ShouldThrow() + { + // Arrange + var view = new ReversedListView(new List()); + + // Act + Assert + Assert.Throws(() => view.Add("a")); + } + + [Fact] + public void Clear_ShouldThrow() + { + // Arrange + var view = new ReversedListView(new List { "a" }); + + // Act + Assert + Assert.Throws(() => view.Clear()); + } + + [Fact] + public void Constructor_WhenInnerNull_ShouldThrow() + { + // Act + Assert + Assert.Throws(() => new ReversedListView(null!)); + } + + [Fact] + public void Contains_ShouldDelegateToInner() + { + // Arrange + var view = new ReversedListView(new List { "a", "b" }); + + // Assert + Assert.Contains("a", view); + Assert.DoesNotContain("c", view); + } + + [Fact] + public void CopyTo_ShouldWriteInReverseOrder() + { + // Arrange + var view = new ReversedListView(new List { "a", "b", "c" }); + var destination = new string[5]; + + // Act + view.CopyTo(destination, 1); + + // Assert + Assert.Equal(new[] { null, "c", "b", "a", null }, destination); + } + + [Fact] + public void CopyTo_WhenArrayNull_ShouldThrow() + { + // Arrange + var view = new ReversedListView(new List { "a" }); + + // Act + Assert + Assert.Throws(() => view.CopyTo(null!, 0)); + } + + [Fact] + public void CopyTo_WhenDestinationTooSmall_ShouldThrow() + { + // Arrange + var view = new ReversedListView(new List { "a", "b", "c" }); + var destination = new string[2]; + + // Act + Assert + Assert.Throws(() => view.CopyTo(destination, 0)); + } + + [Fact] + public void Count_ShouldReflectInnerCount() + { + // Arrange + var inner = new List { 1, 2, 3, 4 }; + var view = new ReversedListView(inner); + + // Act + inner.Add(5); + + // Assert — view sees mutations to the underlying list (acts as a live view). + Assert.Equal(5, view.Count); + Assert.Equal(5, view[0]); + Assert.Equal(1, view[4]); + } + + [Fact] + public void Enumerate_ShouldYieldInReverseOrder() + { + // Arrange + var view = new ReversedListView(new List { "a", "b", "c" }); + + // Act + var result = view.ToList(); + + // Assert + Assert.Equal(new[] { "c", "b", "a" }, result); + } + + [Fact] + public void Indexer_ShouldReturnInnerInReverseOrder() + { + // Arrange + var inner = new List { "a", "b", "c" }; + var view = new ReversedListView(inner); + + // Assert + Assert.Equal("c", view[0]); + Assert.Equal("b", view[1]); + Assert.Equal("a", view[2]); + } + + [Fact] + public void IndexerSetter_ShouldThrow() + { + // Arrange + var view = new ReversedListView(new List { "a" }); + + // Act + Assert + Assert.Throws(() => ((IList)view)[0] = "z"); + } + + [Fact] + public void IndexOf_ShouldReturnPositionInReversedView() + { + // Arrange + var view = new ReversedListView(new List { "a", "b", "c" }); + + // Act + Assert + Assert.Equal(0, view.IndexOf("c")); + Assert.Equal(1, view.IndexOf("b")); + Assert.Equal(2, view.IndexOf("a")); + Assert.Equal(-1, view.IndexOf("d")); + } + + [Fact] + public void IndexOf_WhenItemAppearsTwice_ShouldReturnFirstOccurrenceInReversedView() + { + // Arrange — inner: [a, b, a]; reversed view: [a, b, a]; first occurrence of "a" in reversed view is index 0. + var view = new ReversedListView(new List { "a", "b", "a" }); + + // Act + Assert + Assert.Equal(0, view.IndexOf("a")); + } + + [Fact] + public void Insert_ShouldThrow() + { + // Arrange + var view = new ReversedListView(new List { "a" }); + + // Act + Assert + Assert.Throws(() => view.Insert(0, "z")); + } + + [Fact] + public void IsAssignableToIListT_ShouldExposeFastPathInterface() + { + // Arrange + var view = new ReversedListView(new List { "a" }); + + // Act + Assert + Assert.IsAssignableFrom>(view); + } + + [Fact] + public void IsReadOnly_ShouldBeTrue() + { + // Arrange + var view = new ReversedListView(new List()); + + // Assert + Assert.True(view.IsReadOnly); + } + + [Fact] + public void Remove_ShouldThrow() + { + // Arrange + var view = new ReversedListView(new List { "a" }); + + // Act + Assert + Assert.Throws(() => view.Remove("a")); + } + + [Fact] + public void RemoveAt_ShouldThrow() + { + // Arrange + var view = new ReversedListView(new List { "a" }); + + // Act + Assert + Assert.Throws(() => view.RemoveAt(0)); + } +} diff --git a/src/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.DebugLog.cs b/src/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.DebugLog.cs index 757f1078..e3a89062 100644 --- a/src/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.DebugLog.cs +++ b/src/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.DebugLog.cs @@ -13,7 +13,10 @@ public sealed partial class Constants public const string DebugLogLine2 = "Line 2"; public const string DebugLogLine3 = "Line 3"; public const string DebugLogNewMessage = "New message"; + public const string DebugLogOlderTimestamp = "2026-04-29T07:53:19.0000000-04:00"; public const string DebugLogSecondMessage = "Second message"; public const string DebugLogTestMessage = "Test message"; + public const string DebugLogTestTimestamp = "2026-04-29T07:53:20.9321852-04:00"; + public const int DebugLogTestThreadId = 12; public const string DebugLogThirdMessage = "Third message"; } diff --git a/src/EventLogExpert.UI/Interfaces/IClipboardService.cs b/src/EventLogExpert.UI/Interfaces/IClipboardService.cs index db7e5008..ebd4a2e4 100644 --- a/src/EventLogExpert.UI/Interfaces/IClipboardService.cs +++ b/src/EventLogExpert.UI/Interfaces/IClipboardService.cs @@ -5,7 +5,16 @@ namespace EventLogExpert.UI.Interfaces; public interface IClipboardService { + /// + /// Best-effort copy of the current selection (one or more events), or the focused event when nothing is + /// selected, to the clipboard. Implementations must not throw; any failure is logged internally so callers + /// can invoke without try/catch. + /// Task CopySelectedEvent(CopyType? copyType = null); + /// + /// Best-effort copy of the supplied text to the clipboard. Implementations must not throw; any failure is + /// logged internally so callers can invoke without try/catch. + /// Task CopyTextAsync(string text); } diff --git a/src/EventLogExpert.UI/Interfaces/IFileLogger.cs b/src/EventLogExpert.UI/Interfaces/IFileLogger.cs index 325b2c23..54f2f036 100644 --- a/src/EventLogExpert.UI/Interfaces/IFileLogger.cs +++ b/src/EventLogExpert.UI/Interfaces/IFileLogger.cs @@ -7,5 +7,5 @@ public interface IFileLogger { Task ClearAsync(); - IAsyncEnumerable LoadAsync(); + IAsyncEnumerable LoadAsync(CancellationToken cancellationToken = default); } diff --git a/src/EventLogExpert.UI/Interfaces/IFileSaveService.cs b/src/EventLogExpert.UI/Interfaces/IFileSaveService.cs new file mode 100644 index 00000000..3770cf1f --- /dev/null +++ b/src/EventLogExpert.UI/Interfaces/IFileSaveService.cs @@ -0,0 +1,45 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace EventLogExpert.UI.Interfaces; + +public interface IFileSaveService +{ + /// + /// Opens a system "Save As" dialog and, only after the user confirms a destination, invokes + /// against a writable stream whose contents are then saved to the destination. + /// Returns the saved path on success, or + /// null if the user cancelled (in which case is never invoked). Throws if the + /// user picked a path but the write or completion failed, or if the host environment cannot present a save dialog + /// (e.g., no MAUI window available); callers should handle failures via try/catch and surface them through + /// . + /// + /// Default filename shown in the dialog (e.g., "debug-log.log"). + /// + /// Group label to extension list (e.g., "Log files" -> [".log", ".txt"]). Must contain at least one entry. + /// + /// + /// Asynchronous writer invoked exactly once with a writable stream after the user confirms a destination. + /// Implementations may stream directly to disk or buffer the entire output in memory before writing to the picked + /// file (the MAUI implementation buffers so that a writer exception leaves the picked file untouched); callers + /// should keep exports reasonably bounded to fit in memory. The stream is owned by the service and disposed once + /// the writer completes; callers must not retain or dispose it. + /// + Task SaveAsync( + string suggestedFileName, + IReadOnlyDictionary> fileTypes, + Func writeContent); +} + +public static class FileSaveServiceFileTypes +{ + public static readonly IReadOnlyDictionary> Json = + ImmutableDictionary>.Empty + .Add("JSON", [".json"]); + + public static readonly IReadOnlyDictionary> Log = + ImmutableDictionary>.Empty + .Add("Log files", [".log", ".txt"]); +} diff --git a/src/EventLogExpert.UI/Models/DebugLogEntry.cs b/src/EventLogExpert.UI/Models/DebugLogEntry.cs new file mode 100644 index 00000000..fbeef451 --- /dev/null +++ b/src/EventLogExpert.UI/Models/DebugLogEntry.cs @@ -0,0 +1,16 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace EventLogExpert.UI.Models; + +public sealed record DebugLogEntry( + DateTimeOffset? Timestamp, + int? ThreadId, + LogLevel? Level, + int MessageStartIndex, + string RawLine) +{ + public string Message => RawLine[MessageStartIndex..]; +} diff --git a/src/EventLogExpert.UI/Services/DebugLogEntryParser.cs b/src/EventLogExpert.UI/Services/DebugLogEntryParser.cs new file mode 100644 index 00000000..09911fd2 --- /dev/null +++ b/src/EventLogExpert.UI/Services/DebugLogEntryParser.cs @@ -0,0 +1,162 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Models; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace EventLogExpert.UI.Services; + +/// +/// Parses lines written by into structured records. +/// The writer formats each entry as +/// [{DateTime.Now:o}] [{Environment.CurrentManagedThreadId}] [{level}] {message}. Lines whose prefix does not +/// parse fully are treated as continuations of the previous entry (e.g., subsequent lines of a multi-line stack trace) +/// or, if there is no previous entry, as standalone entries with all metadata fields set to null. +/// +public static partial class DebugLogEntryParser +{ + /// + /// Parses a complete sequence of log lines into entries. Continuation lines fold into the previous entry's + /// and joined by \n. A continuation + /// line with no preceding entry becomes a standalone entry with , + /// , and all set to null. + /// + public static IReadOnlyList Parse(IEnumerable lines) + { + ArgumentNullException.ThrowIfNull(lines); + + var entries = new List(); + var parser = new DebugLogEntryStreamParser(); + + foreach (var line in lines) + { + var emitted = parser.AddLine(line); + + if (emitted is not null) { entries.Add(emitted); } + } + + var final = parser.Flush(); + + if (final is not null) { entries.Add(final); } + + return entries; + } + + /// + /// Attempts to parse a single line as a new entry start. Returns true and emits the parsed entry only when all + /// three prefix fields (timestamp, thread id, level) parse successfully. The emitted entry's + /// equals . + /// + public static bool TryParseLine(string line, [NotNullWhen(true)] out DebugLogEntry? entry) + { + ArgumentNullException.ThrowIfNull(line); + + var match = LinePrefixRegex().Match(line); + + if (!match.Success || + !DateTimeOffset.TryParseExact(match.Groups["ts"].Value, + "o", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var timestamp) || + !int.TryParse(match.Groups["tid"].Value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var threadId) || + !Enum.TryParse(match.Groups["level"].Value, true, out var level)) + { + entry = null; + + return false; + } + + entry = new DebugLogEntry(timestamp, threadId, level, match.Groups["message"].Index, line); + + return true; + } + + [GeneratedRegex(@"^\[(?[^\]]+)\] \[(?\d+)\] \[(?[A-Za-z]+)\] (?.*)$")] + private static partial Regex LinePrefixRegex(); +} + +/// +/// Stateful streaming parser for output. Emits a completed +/// from the moment a new entry header arrives (with all preceding +/// continuation lines folded into the previous entry's and +/// ). Call after the final line to drain any remaining +/// pending entry. +/// +/// +/// This type exists so callers reading from an asynchronous source (e.g., ) +/// can render entries incrementally rather than buffering the entire stream and parsing in one shot. The non-streaming +/// overload is implemented in terms of this type. +/// +public sealed class DebugLogEntryStreamParser +{ + private List? _continuationLines; + private DebugLogEntry? _pending; + + /// + /// Feeds one line into the parser. Returns the previously buffered entry (with its continuation lines folded in) + /// when starts a new entry; otherwise returns null and buffers the line internally. + /// Any line whose prefix parses successfully starts a new entry, regardless of timestamp ordering relative to the + /// pending entry (older releases formatted timestamps before acquiring the writer lock, so persisted logs may + /// legitimately contain headers in non-chronological order). + /// + public DebugLogEntry? AddLine(string line) + { + ArgumentNullException.ThrowIfNull(line); + + if (DebugLogEntryParser.TryParseLine(line, out var parsed)) + { + var emitted = BuildPending(); + + _pending = parsed; + _continuationLines = null; + + return emitted; + } + + if (_pending is null) + { + _pending = new DebugLogEntry(null, null, null, 0, line); + + return null; + } + + _continuationLines ??= []; + _continuationLines.Add(line); + + return null; + } + + /// + /// Returns the final pending entry (with continuation lines folded in) and resets state. Returns null when + /// no entry is currently buffered. + /// + public DebugLogEntry? Flush() + { + var emitted = BuildPending(); + + _pending = null; + _continuationLines = null; + + return emitted; + } + + private DebugLogEntry? BuildPending() + { + if (_pending is null) { return null; } + + if (_continuationLines is not { Count: > 0 }) { return _pending; } + + var joinedContinuations = string.Join('\n', _continuationLines); + + _pending = _pending with { RawLine = _pending.RawLine + '\n' + joinedContinuations }; + + return _pending; + } +} diff --git a/src/EventLogExpert.UI/Services/DebugLogProjection.cs b/src/EventLogExpert.UI/Services/DebugLogProjection.cs new file mode 100644 index 00000000..f041d09c --- /dev/null +++ b/src/EventLogExpert.UI/Services/DebugLogProjection.cs @@ -0,0 +1,87 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Models; +using Microsoft.Extensions.Logging; + +namespace EventLogExpert.UI.Services; + +public static class DebugLogProjection +{ + public static (List Lines, int MatchedEntryCount) Project( + IReadOnlyList entries, + FilterEvaluator levelOperator, + IReadOnlyList levels, + string? textFilter) + { + ArgumentNullException.ThrowIfNull(entries); + + return ProjectRange(entries, 0, entries.Count, levelOperator, levels, textFilter); + } + + public static (List Lines, int MatchedEntryCount) ProjectRange( + IReadOnlyList entries, + int startIndex, + int endIndex, + FilterEvaluator levelOperator, + IReadOnlyList levels, + string? textFilter) + { + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(levels); + ArgumentOutOfRangeException.ThrowIfNegative(startIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThan(endIndex, entries.Count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(startIndex, endIndex); + + var sliceLength = endIndex - startIndex; + var lines = new List(sliceLength); + var matchedCount = 0; + + var hasLevelFilter = levels.Count > 0; + var hasTextFilter = !string.IsNullOrEmpty(textFilter); + + for (var i = startIndex; i < endIndex; i++) + { + var entry = entries[i]; + + if (hasLevelFilter && !MatchesLevel(entry, levelOperator, levels)) { continue; } + + if (hasTextFilter && + entry.RawLine.IndexOf(textFilter!, entry.MessageStartIndex, StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + matchedCount++; + AppendPhysicalLinesReversed(entry.RawLine, lines); + } + + return (lines, matchedCount); + } + + private static void AppendPhysicalLinesReversed(string raw, List lines) + { + if (raw.Length == 0) + { + lines.Add(string.Empty); + + return; + } + + var split = raw.Split('\n'); + + for (var i = split.Length - 1; i >= 0; i--) + { + lines.Add(split[i]); + } + } + + private static bool MatchesLevel(DebugLogEntry entry, FilterEvaluator op, IReadOnlyList levels) + { + if (entry.Level is null) { return op == FilterEvaluator.NotEqual; } + + var contains = levels.Contains(entry.Level.Value); + + return op == FilterEvaluator.NotEqual ? !contains : contains; + } +} diff --git a/src/EventLogExpert.UI/Services/DebugLogService.cs b/src/EventLogExpert.UI/Services/DebugLogService.cs index fbdbc41d..82a99584 100644 --- a/src/EventLogExpert.UI/Services/DebugLogService.cs +++ b/src/EventLogExpert.UI/Services/DebugLogService.cs @@ -6,6 +6,8 @@ using EventLogExpert.UI.Options; using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; namespace EventLogExpert.UI.Services; @@ -13,7 +15,10 @@ public sealed partial class DebugLogService : ITraceLogger, IFileLogger, IDispos { private const long MaxLogSize = 10 * 1024 * 1024; + private static readonly TimeSpan s_interprocessLockTimeout = TimeSpan.FromSeconds(2); + private readonly FileLocationOptions _fileLocationOptions; + private readonly Mutex _interprocessMutex; private readonly ISettingsService _settings; private readonly Lock _writeLock = new(); @@ -25,6 +30,7 @@ public DebugLogService(FileLocationOptions fileLocationOptions, ISettingsService _fileLocationOptions = fileLocationOptions; _settings = settings; _cachedLogLevel = _settings.LogLevel; + _interprocessMutex = new Mutex(false, DeriveMutexName(_fileLocationOptions.LoggingPath)); InitTracing(); @@ -40,7 +46,20 @@ public Task ClearAsync() using (_writeLock.EnterScope()) { CloseWriter(); - File.WriteAllText(_fileLocationOptions.LoggingPath, string.Empty); + + WithInterprocessLock( + () => + { + // Share with concurrent writers; SetLength(0) truncates without closing them. + using var stream = new FileStream( + _fileLocationOptions.LoggingPath, + FileMode.OpenOrCreate, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + + stream.SetLength(0); + }, + throwOnTimeout: true); } return Task.CompletedTask; @@ -70,6 +89,8 @@ public void Dispose() { CloseWriter(); } + + _interprocessMutex.Dispose(); } public void Error([InterpolatedStringHandlerArgument("")] ErrorLogHandler handler) @@ -86,12 +107,10 @@ public void Info([InterpolatedStringHandlerArgument("")] InfoLogHandler handler) WriteTrace(handler.ToStringAndClear(), LogLevel.Information); } - public async IAsyncEnumerable LoadAsync() + public async IAsyncEnumerable LoadAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Read directly from the source file. Writers use File.AppendText which opens with - // FileShare.Read, allowing concurrent readers. We open with FileShare.ReadWrite | Delete - // to allow concurrent writers and log rotation/deletion (e.g., InitTracing deletes oversized logs). - // This avoids the overhead of copying to a temp file and eliminates lock contention. + // ReadWrite + Delete share: concurrent writers (second app instance) and rotation-driven deletion. var options = new FileStreamOptions { Mode = FileMode.Open, @@ -109,8 +128,7 @@ public async IAsyncEnumerable LoadAsync() } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { - // The log file doesn't exist yet (fresh install, before first write), - // or was deleted between check and open (e.g., InitTracing deletes oversized logs). + // Log not yet created (fresh install) or rotation deleted it; treat as empty. yield break; } @@ -118,7 +136,7 @@ public async IAsyncEnumerable LoadAsync() { using var reader = new StreamReader(stream); - while (await reader.ReadLineAsync() is { } line) + while (await reader.ReadLineAsync(cancellationToken) is { } line) { yield return line; } @@ -149,11 +167,12 @@ private void EnsureWriter() { if (_writer is not null) { return; } + // OpenOrCreate (not Append) avoids stale-position writes after cross-instance SetLength(0). var stream = new FileStream( _fileLocationOptions.LoggingPath, - FileMode.Append, + FileMode.OpenOrCreate, FileAccess.Write, - FileShare.Read | FileShare.Delete, + FileShare.ReadWrite | FileShare.Delete, bufferSize: 4096); _writer = new StreamWriter(stream) { AutoFlush = true }; @@ -181,16 +200,73 @@ private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) private void WriteTrace(string message, LogLevel level) { - string output = $"[{DateTime.Now:o}] [{Environment.CurrentManagedThreadId}] [{level}] {message}"; - using (_writeLock.EnterScope()) { + string output = $"[{DateTime.Now:o}] [{Environment.CurrentManagedThreadId}] [{level}] {message}"; + EnsureWriter(); - _writer?.WriteLine(output); - } + + // Mutex serializes line writes so concurrent instances don't interleave. + WithInterprocessLock( + () => + { + if (_writer is null) { return; } + + // Re-seek to EOF: another instance may have written or truncated the file. + _writer.BaseStream.Seek(0, SeekOrigin.End); + _writer.WriteLine(output); + }, + throwOnTimeout: false); #if DEBUG - System.Diagnostics.Debug.WriteLine(output); + System.Diagnostics.Debug.WriteLine(output); #endif + } + } + + private static string DeriveMutexName(string path) + { + // Canonicalize: equivalent paths (relative, separator drift) must hash identically. + var canonical = Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var bytes = Encoding.UTF8.GetBytes(canonical.ToUpperInvariant()); + var hash = SHA256.HashData(bytes); + return $"Local\\EventLogExpert.DebugLog.{Convert.ToHexString(hash, 0, 8)}"; + } + + private void WithInterprocessLock(Action action, bool throwOnTimeout) + { + bool acquired; + + try + { + acquired = _interprocessMutex.WaitOne(s_interprocessLockTimeout); + } + catch (AbandonedMutexException) + { + // Prior owner crashed without releasing; we now hold it and proceed. + acquired = true; + } + + if (!acquired) + { + if (throwOnTimeout) + { + throw new TimeoutException( + $"Timed out acquiring debug-log interprocess lock after {s_interprocessLockTimeout.TotalSeconds:F0}s."); + } + + // Hot path: drop this trace rather than risk interleaved/corrupted writes. + return; + } + + try + { + action(); + } + finally + { + _interprocessMutex.ReleaseMutex(); + } } } diff --git a/src/EventLogExpert.UI/Services/ReversedListView.cs b/src/EventLogExpert.UI/Services/ReversedListView.cs new file mode 100644 index 00000000..5372574a --- /dev/null +++ b/src/EventLogExpert.UI/Services/ReversedListView.cs @@ -0,0 +1,79 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections; + +namespace EventLogExpert.UI.Services; + +public sealed class ReversedListView : IReadOnlyList, IList +{ + private readonly IList _inner; + + public ReversedListView(IList inner) + { + ArgumentNullException.ThrowIfNull(inner); + + _inner = inner; + } + + public int Count => _inner.Count; + + public bool IsReadOnly => true; + + public T this[int index] + { + get => _inner[_inner.Count - 1 - index]; + set => throw new NotSupportedException(); + } + + public void Add(T item) => throw new NotSupportedException(); + + public void Clear() => throw new NotSupportedException(); + + public bool Contains(T item) => _inner.Contains(item); + + public void CopyTo(T[] array, int arrayIndex) + { + ArgumentNullException.ThrowIfNull(array); + ArgumentOutOfRangeException.ThrowIfNegative(arrayIndex); + + if (array.Length - arrayIndex < _inner.Count) + { + throw new ArgumentException("Destination array is too small.", nameof(array)); + } + + for (var i = 0; i < _inner.Count; i++) + { + array[arrayIndex + i] = this[i]; + } + } + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < _inner.Count; i++) + { + yield return this[i]; + } + } + + public int IndexOf(T item) + { + for (var i = 0; i < _inner.Count; i++) + { + if (EqualityComparer.Default.Equals(this[i], item)) + { + return i; + } + } + + return -1; + } + + public void Insert(int index, T item) => throw new NotSupportedException(); + + public bool Remove(T item) => throw new NotSupportedException(); + + public void RemoveAt(int index) => throw new NotSupportedException(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/EventLogExpert/Components/Modals/Filters/FilterCacheModal.razor.cs b/src/EventLogExpert/Components/Modals/Filters/FilterCacheModal.razor.cs index e4c57ec5..1cd1bdbe 100644 --- a/src/EventLogExpert/Components/Modals/Filters/FilterCacheModal.razor.cs +++ b/src/EventLogExpert/Components/Modals/Filters/FilterCacheModal.razor.cs @@ -10,8 +10,6 @@ using Fluxor; using Microsoft.AspNetCore.Components; using System.Text.Json; -using Windows.Storage.Pickers; -using WinRT.Interop; using IDispatcher = Fluxor.IDispatcher; namespace EventLogExpert.Components.Modals.Filters; @@ -22,38 +20,20 @@ public sealed partial class FilterCacheModal : ModalBase [Inject] private IDispatcher Dispatcher { get; init; } = null!; + [Inject] private IFileSaveService FileSaveService { get; init; } = null!; + [Inject] private IState FilterCacheState { get; init; } = null!; protected override async Task OnExportAsync() { - FileSavePicker picker = new() - { - SuggestedStartLocation = PickerLocationId.DocumentsLibrary, - SuggestedFileName = "Saved Filters" - }; - - picker.FileTypeChoices.Add("JSON", [".json"]); - - if (Application.Current?.Windows[0].Handler?.PlatformView is not MauiWinUIWindow window) - { - return; - } - - InitializeWithWindow.Initialize(picker, window.WindowHandle); - - var result = await picker.PickSaveFileAsync(); - - if (result is null) { return; } + var snapshot = FilterCacheState.Value.FavoriteFilters; try { - using var stream = new MemoryStream( - JsonSerializer.SerializeToUtf8Bytes( - FilterCacheState.Value.FavoriteFilters)); - - await using var fileStream = await result.OpenStreamForWriteAsync(); - - await stream.CopyToAsync(fileStream); + await FileSaveService.SaveAsync( + "Saved Filters", + FileSaveServiceFileTypes.Json, + stream => JsonSerializer.SerializeAsync(stream, snapshot)); } catch (Exception ex) { diff --git a/src/EventLogExpert/Components/Modals/Filters/FilterGroup.razor.cs b/src/EventLogExpert/Components/Modals/Filters/FilterGroup.razor.cs index ec5c014c..ffbe2dfb 100644 --- a/src/EventLogExpert/Components/Modals/Filters/FilterGroup.razor.cs +++ b/src/EventLogExpert/Components/Modals/Filters/FilterGroup.razor.cs @@ -8,8 +8,6 @@ using EventLogExpert.UI.Store.FilterPane; using Microsoft.AspNetCore.Components; using System.Text.Json; -using Windows.Storage.Pickers; -using WinRT.Interop; using IDispatcher = Fluxor.IDispatcher; namespace EventLogExpert.Components.Modals.Filters; @@ -31,6 +29,8 @@ public sealed partial class FilterGroup [Inject] private IDispatcher Dispatcher { get; init; } = null!; + [Inject] private IFileSaveService FileSaveService { get; init; } = null!; + protected override void OnParametersSet() { // Group identity swap: FilterGroupModal reuses component instances when the sorted list reorders. @@ -82,32 +82,14 @@ private async Task CopyGroup() private async Task ExportGroup() { - FileSavePicker picker = new() - { - SuggestedStartLocation = PickerLocationId.DocumentsLibrary, - SuggestedFileName = Group.DisplayName - }; - - picker.FileTypeChoices.Add("JSON", new List { ".json" }); - - if (Application.Current?.Windows[0].Handler?.PlatformView is not MauiWinUIWindow window) - { - return; - } - - InitializeWithWindow.Initialize(picker, window.WindowHandle); - - var result = await picker.PickSaveFileAsync(); - - if (result is null) { return; } + var snapshot = Group; try { - using var stream = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(Group)); - - await using var fileStream = await result.OpenStreamForWriteAsync(); - - await stream.CopyToAsync(fileStream); + await FileSaveService.SaveAsync( + snapshot.DisplayName, + FileSaveServiceFileTypes.Json, + stream => JsonSerializer.SerializeAsync(stream, snapshot)); } catch (Exception ex) { diff --git a/src/EventLogExpert/Components/Modals/Filters/FilterGroupModal.razor.cs b/src/EventLogExpert/Components/Modals/Filters/FilterGroupModal.razor.cs index bab0917e..90a1e94e 100644 --- a/src/EventLogExpert/Components/Modals/Filters/FilterGroupModal.razor.cs +++ b/src/EventLogExpert/Components/Modals/Filters/FilterGroupModal.razor.cs @@ -8,8 +8,6 @@ using Fluxor; using Microsoft.AspNetCore.Components; using System.Text.Json; -using Windows.Storage.Pickers; -using WinRT.Interop; using IDispatcher = Fluxor.IDispatcher; namespace EventLogExpert.Components.Modals.Filters; @@ -20,38 +18,20 @@ public sealed partial class FilterGroupModal : ModalBase [Inject] private IDispatcher Dispatcher { get; init; } = null!; + [Inject] private IFileSaveService FileSaveService { get; init; } = null!; + [Inject] private IState FilterGroupState { get; init; } = null!; protected override async Task OnExportAsync() { - FileSavePicker picker = new() - { - SuggestedStartLocation = PickerLocationId.DocumentsLibrary, - SuggestedFileName = "Saved Groups" - }; - - picker.FileTypeChoices.Add("JSON", new List { ".json" }); - - if (Application.Current?.Windows[0].Handler?.PlatformView is not MauiWinUIWindow window) - { - return; - } - - InitializeWithWindow.Initialize(picker, window.WindowHandle); - - var result = await picker.PickSaveFileAsync(); - - if (result is null) { return; } + var snapshot = FilterGroupState.Value.Groups; try { - using var stream = new MemoryStream( - JsonSerializer.SerializeToUtf8Bytes( - FilterGroupState.Value.Groups)); - - await using var fileStream = await result.OpenStreamForWriteAsync(); - - await stream.CopyToAsync(fileStream); + await FileSaveService.SaveAsync( + "Saved Groups", + FileSaveServiceFileTypes.Json, + stream => JsonSerializer.SerializeAsync(stream, snapshot)); } catch (Exception ex) { diff --git a/src/EventLogExpert/MauiProgram.cs b/src/EventLogExpert/MauiProgram.cs index 8d86ddd5..5e3e9064 100644 --- a/src/EventLogExpert/MauiProgram.cs +++ b/src/EventLogExpert/MauiProgram.cs @@ -97,6 +97,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/EventLogExpert/Platforms/Windows/FolderPickerHelper.cs b/src/EventLogExpert/Platforms/Windows/FolderPickerHelper.cs index 641ae447..6edd654a 100644 --- a/src/EventLogExpert/Platforms/Windows/FolderPickerHelper.cs +++ b/src/EventLogExpert/Platforms/Windows/FolderPickerHelper.cs @@ -9,6 +9,11 @@ namespace EventLogExpert.Platforms.Windows; public static class FolderPickerHelper { + /// + /// Presents the WinUI folder picker. Returns the selected folder's path, or null only when the + /// user cancelled. Throws when no MAUI host window is available + /// so callers can surface the broken-host condition instead of silently treating it as a cancel. + /// public static async Task PickFolderAsync() { FolderPicker picker = new() @@ -17,7 +22,14 @@ public static class FolderPickerHelper FileTypeFilter = { "*" } // Add a wildcard to allow folder selection }; - if (Application.Current?.Windows[0].Handler?.PlatformView is not MauiWinUIWindow window) { return null; } + var current = Application.Current; + var hostWindow = current?.Windows.Count > 0 ? current.Windows[0] : null; + + if (hostWindow?.Handler?.PlatformView is not MauiWinUIWindow window) + { + throw new InvalidOperationException( + "No MAUI host window is available to present the folder picker."); + } InitializeWithWindow.Initialize(picker, window.WindowHandle); diff --git a/src/EventLogExpert/Services/MauiFileSaveService.cs b/src/EventLogExpert/Services/MauiFileSaveService.cs new file mode 100644 index 00000000..0e95bdb8 --- /dev/null +++ b/src/EventLogExpert/Services/MauiFileSaveService.cs @@ -0,0 +1,86 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Interfaces; +using Windows.Storage; +using Windows.Storage.Pickers; +using Windows.Storage.Provider; +using WinRT.Interop; + +namespace EventLogExpert.Services; + +public sealed class MauiFileSaveService : IFileSaveService +{ + public async Task SaveAsync( + string suggestedFileName, + IReadOnlyDictionary> fileTypes, + Func writeContent) + { + ArgumentNullException.ThrowIfNull(suggestedFileName); + ArgumentNullException.ThrowIfNull(fileTypes); + ArgumentNullException.ThrowIfNull(writeContent); + + if (fileTypes.Count == 0) + { + throw new ArgumentException( + "At least one file-type choice must be supplied.", nameof(fileTypes)); + } + + var pickedFile = await MainThread.InvokeOnMainThreadAsync(async () => + { + var picker = new FileSavePicker + { + SuggestedStartLocation = PickerLocationId.DocumentsLibrary, + SuggestedFileName = suggestedFileName + }; + + foreach ((string label, IReadOnlyList extensions) in fileTypes) + { + picker.FileTypeChoices.Add(label, [.. extensions]); + } + + var current = Application.Current; + var hostWindow = current?.Windows.Count > 0 ? current.Windows[0] : null; + + if (hostWindow?.Handler?.PlatformView is not MauiWinUIWindow window) + { + throw new InvalidOperationException( + "No MAUI host window is available to present the Save As dialog."); + } + + InitializeWithWindow.Initialize(picker, window.WindowHandle); + + return await picker.PickSaveFileAsync(); + }); + + if (pickedFile is null) + { + return null; + } + + // Buffer first; picked file stays untouched if writeContent throws. + using var buffer = new MemoryStream(); + await writeContent(buffer); + buffer.Position = 0; + + // Required for provider-backed destinations (e.g., OneDrive). + CachedFileManager.DeferUpdates(pickedFile); + + FileUpdateStatus status; + + try + { + await using var fileStream = await pickedFile.OpenStreamForWriteAsync(); + // OpenStreamForWriteAsync does not truncate; without this, larger files leave stale trailing bytes. + fileStream.SetLength(0); + await buffer.CopyToAsync(fileStream); + } + finally + { + status = await CachedFileManager.CompleteUpdatesAsync(pickedFile); + } + + return status is not (FileUpdateStatus.Complete or FileUpdateStatus.CompleteAndRenamed) ? + throw new IOException($"Save failed with status '{status}'.") : pickedFile.Path; + } +} diff --git a/src/EventLogExpert/Services/MauiMenuActionService.cs b/src/EventLogExpert/Services/MauiMenuActionService.cs index dd137994..f381b5d5 100644 --- a/src/EventLogExpert/Services/MauiMenuActionService.cs +++ b/src/EventLogExpert/Services/MauiMenuActionService.cs @@ -159,13 +159,35 @@ public async Task OpenFileAsync(bool combineLog) public async Task OpenFolderAsync(bool combineLog) { - string? folderPath = await FolderPickerHelper.PickFolderAsync(); + string? folderPath; + + try + { + folderPath = await FolderPickerHelper.PickFolderAsync(); + } + catch (InvalidOperationException ex) + { + await _dialogService.ShowAlert("Open Folder Failed", ex.Message, "OK"); + + return; + } if (folderPath is null) { return; } - var files = Directory.EnumerateFiles(folderPath, "*.evtx", SearchOption.TopDirectoryOnly) - .Select(file => (file, PathType.FilePath)) - .ToList(); + List<(string, PathType)> files; + + try + { + files = Directory.EnumerateFiles(folderPath, "*.evtx", SearchOption.TopDirectoryOnly) + .Select(file => (file, PathType.FilePath)) + .ToList(); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + await _dialogService.ShowAlert("Open Folder Failed", ex.Message, "OK"); + + return; + } if (files.Count == 0) { return; } diff --git a/src/EventLogExpert/Services/TitleProvider.cs b/src/EventLogExpert/Services/TitleProvider.cs index ca62f18d..78fea865 100644 --- a/src/EventLogExpert/Services/TitleProvider.cs +++ b/src/EventLogExpert/Services/TitleProvider.cs @@ -9,18 +9,17 @@ public class TitleProvider : ITitleProvider { public string GetTitle() { - return Application.Current?.Windows[0].Title ?? ""; + var current = Application.Current; + return (current?.Windows.Count > 0 ? current.Windows[0].Title : null) ?? ""; } public void SetTitle(string title) { - MainThread.InvokeOnMainThreadAsync(() => + MainThread.BeginInvokeOnMainThread(() => { - var window = Application.Current?.Windows[0]; - if (window is not null) - { - window.Title = title.ToString(); - } + var current = Application.Current; + var window = current?.Windows.Count > 0 ? current.Windows[0] : null; + window?.Title = title; }); } } diff --git a/src/EventLogExpert/wwwroot/css/app.css b/src/EventLogExpert/wwwroot/css/app.css index 7f4fb748..77c6db87 100644 --- a/src/EventLogExpert/wwwroot/css/app.css +++ b/src/EventLogExpert/wwwroot/css/app.css @@ -163,12 +163,16 @@ html, body { .footer-group { display: flex; justify-content: space-between; + align-items: center; + gap: 0.5rem; margin-top: .5rem; } .footer-group.align-right { justify-content: flex-end; } + .footer-group .footer-extra { flex: 1 1 auto; min-width: 0; } + a { color: var(--clr-lightblue); } .spinner-border {