From b2f0686ac0d9edccda15541bf115efaa2a79a203 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 13:26:27 -0500 Subject: [PATCH] fix(logging): keep truncation notices visible while following Show the bounded-tail notice in both viewer toolbars and scroll the WPF viewer after layout so its newest entry is visible on first open. Exercise huge logs through both sample apps and use physical coordinates for synchronous UI tests on scaled desktops. Refs #18, #20 Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 4 +- src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs | 6 ++ src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs | 9 +++ .../KofTwentyTwo.AppKit.UiTests/AppSession.cs | 55 +++++++++++-------- .../DpiAwarenessScope.cs | 39 +++++++++++++ .../SampleAppTests.cs | 48 ++++++++++++++++ 6 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6953f..6880754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ versions retain their original package contents. - Bound each log-viewer refresh to 1 MiB of trailing input plus the encoding header, including huge single lines. Preserve supported BOM encodings and show a notice - when older input is omitted. Existing two-argument log-reader calls remain compatible. + when older input is omitted, including a persistent toolbar notice while following + new entries. WPF follows the newest entry on first open and subsequent refreshes. + Existing two-argument log-reader calls remain compatible. - Add an `ILogger` adapter for activity logs with original message templates, typed scalar fields, event IDs, and asynchronous scopes stored as JSON. Existing `IActivityLog` methods and third-party implementations remain compatible. Foundation diff --git a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs index 50fd616..30912c4 100644 --- a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs +++ b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs @@ -5,6 +5,7 @@ using KofTwentyTwo.AppKit.Logging; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Media; @@ -26,6 +27,7 @@ public sealed partial class LogWindow : Window private readonly IActivityLog _log; private readonly DispatcherTimer _refresh = new() { Interval = s_refreshInterval }; private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7 }; + private readonly TextBlock _limitNotice = new() { Text = "Older log content omitted", VerticalAlignment = VerticalAlignment.Center, Visibility = Visibility.Collapsed }; private readonly TextBlock _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsTextSelectionEnabled = true, TextWrapping = TextWrapping.NoWrap }; private readonly ScrollViewer _scroll = new() { HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) }; private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true }; @@ -42,6 +44,8 @@ public sealed partial class LogWindow : Window ArgumentNullException.ThrowIfNull(app); _log = log; Title = app.DisplayName + " — Activity log"; + AutomationProperties.SetAutomationId(_logText, "LogText"); + AutomationProperties.SetAutomationId(_limitNotice, "LogTruncationNotice"); var copy = new Button { Content = "Copy" }; var openFolder = new Button { Content = "Open logs folder" }; @@ -60,6 +64,7 @@ public sealed partial class LogWindow : Window toolbar.Children.Add(_errorsOnly); toolbar.Children.Add(copy); toolbar.Children.Add(openFolder); + toolbar.Children.Add(_limitNotice); toolbar.Children.Add(_pathText); _scroll.Content = _logText; @@ -146,6 +151,7 @@ public sealed partial class LogWindow : Window _pathText.Text = _log.CurrentLogFilePath; // the day can roll over while the window is open LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath); _lastLength = snapshot.Length; + _limitNotice.Visibility = snapshot.IsTruncated ? Visibility.Visible : Visibility.Collapsed; bool errorsOnly = _errorsOnly.IsChecked == true; _logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly, snapshot.IsTruncated); diff --git a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs index 8427178..9753e1c 100644 --- a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs +++ b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.IO; using System.Windows; +using System.Windows.Automation; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Media; @@ -27,6 +28,7 @@ public sealed class LogWindow : Window private readonly IActivityLog _log; private readonly DispatcherTimer _refresh = new() { Interval = TimeSpan.FromSeconds(1) }; private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7, Margin = new Thickness(8, 0, 0, 0) }; + private readonly TextBlock _limitNotice = new() { Text = "Older log content omitted", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 0, 0), Visibility = Visibility.Collapsed }; private readonly TextBox _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsReadOnly = true, BorderThickness = new Thickness(0), HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) }; private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true, Padding = new Thickness(10, 4, 10, 4) }; private readonly ToggleButton _errorsOnly = new() { Content = "Errors only", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) }; @@ -42,6 +44,9 @@ public sealed class LogWindow : Window ArgumentNullException.ThrowIfNull(app); _log = log; Title = app.DisplayName + " — Activity log"; + AutomationProperties.SetAutomationId(_logText, "LogText"); + AutomationProperties.SetAutomationId(_limitNotice, "LogTruncationNotice"); + _logText.Loaded += (_, _) => _logText.ScrollToEnd(); Width = 1000; Height = 680; this.SetAppIcon(app); @@ -66,6 +71,8 @@ public sealed class LogWindow : Window buttons.Children.Add(openFolder); DockPanel.SetDock(buttons, Dock.Left); toolbar.Children.Add(buttons); + DockPanel.SetDock(_limitNotice, Dock.Right); + toolbar.Children.Add(_limitNotice); toolbar.Children.Add(_pathText); var root = new DockPanel(); @@ -134,8 +141,10 @@ public sealed class LogWindow : Window _pathText.Text = _log.CurrentLogFilePath; LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath); _lastLength = snapshot.Length; + _limitNotice.Visibility = snapshot.IsTruncated ? Visibility.Visible : Visibility.Collapsed; bool errorsOnly = _errorsOnly.IsChecked == true; _logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly, snapshot.IsTruncated); + _logText.UpdateLayout(); _logText.ScrollToEnd(); } } diff --git a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs index 8ff5d90..d3101eb 100644 --- a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs +++ b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs @@ -36,6 +36,7 @@ public sealed class AppSession : IDisposable private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan s_launchTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan s_pollInterval = TimeSpan.FromMilliseconds(250); + private readonly DpiAwarenessScope _dpi; @@ -52,9 +53,10 @@ public sealed class AppSession : IDisposable var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" }; startInfo.Environment["APPKIT_SAMPLE_DATA_DIR"] = DataDirectory; - Automation = new UIA3Automation(); + _dpi = new DpiAwarenessScope(); try { + Automation = new UIA3Automation(); App = Application.Launch(startInfo); MainWindow = WaitForMainWindow(); } @@ -295,32 +297,39 @@ public sealed class AppSession : IDisposable { try { - App?.Close(); - } - catch - { - // Already gone; the kill below double-checks. - } - try - { - if(App is { HasExited: false }) + try { - App.Kill(); + App?.Close(); + } + catch + { + // Already gone; the kill below double-checks. + } + try + { + if(App is { HasExited: false }) + { + App.Kill(); + } + } + catch + { + // Best effort. + } + App?.Dispose(); + Automation?.Dispose(); + try + { + Directory.Delete(DataDirectory, recursive: true); + } + catch + { + // A log handle can linger briefly after exit; the temp cleaner gets it. } } - catch + finally { - // Best effort. - } - App?.Dispose(); - Automation.Dispose(); - try - { - Directory.Delete(DataDirectory, recursive: true); - } - catch - { - // A log handle can linger briefly after exit; the temp cleaner gets it. + _dpi.Dispose(); } } } diff --git a/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs b/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs new file mode 100644 index 0000000..78b7e61 --- /dev/null +++ b/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.ComponentModel; +using System.Runtime.InteropServices; + + +namespace KofTwentyTwo.AppKit.UiTests; + +/// Uses physical screen coordinates for UI input and capture, restoring the synchronous test thread afterwards. +internal sealed class DpiAwarenessScope : IDisposable +{ + private readonly nint _previous; + + + + /// Disables DPI virtualization on this thread with per-monitor awareness v2 (Windows 10 1703+). + public DpiAwarenessScope() + { + _previous = SetThreadDpiAwarenessContext(-4); + if(_previous == 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + } + + + + /// Restores the context on the same thread that ran the synchronous UI test. + public void Dispose() => SetThreadDpiAwarenessContext(_previous); + + + + /// Windows' thread-scoped DPI override, supported by the repository's minimum OS. + [DllImport("user32.dll", ExactSpelling = true, SetLastError = true)] + private static extern nint SetThreadDpiAwarenessContext(nint dpiContext); +} diff --git a/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs b/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs index c3d5d13..e5c0de3 100644 --- a/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs +++ b/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs @@ -3,7 +3,9 @@ * SPDX-License-Identifier: MIT */ +using System.Drawing; using FlaUI.Core.AutomationElements; +using FlaUI.Core.Capturing; namespace KofTwentyTwo.AppKit.UiTests; @@ -76,6 +78,52 @@ public class SampleAppTests + /// Huge logs retain the newest entries and a visible notice even while Follow scrolls to the end. + [Theory] + [InlineData(SampleKind.WinUI)] + [InlineData(SampleKind.Wpf)] + public void ActivityLog_HugeFile_ShowsLatestEntryAndPersistentLimitNotice(SampleKind kind) + { + using var session = new AppSession(kind); + string path = Directory.GetFiles(Path.Combine(session.DataDirectory, "logs"), "*.log").Single(); + using(var writer = new StreamWriter(path, append: true)) + { + string padding = new('x', 80); + for(int index = 0; index < 30_000; index++) + { + writer.WriteLine("2026-10-10 12:00:00.000 [INFO] Older entry " + padding); + } + writer.WriteLine("2026-10-10 12:00:01.000 [INFO] Latest entry marker"); + } + session.InvokeMenuItem("Help", "ActivityLogMenuItem"); + AutomationElement text = session.WaitForElement("LogText"); + string contents = kind == SampleKind.WinUI ? text.Name : text.AsTextBox().Text; + Assert.Contains("Latest entry marker", contents, StringComparison.Ordinal); + Assert.StartsWith("Showing a bounded log tail;", contents, StringComparison.Ordinal); + Assert.True(contents.Length < 1024 * 1024); + session.WaitFor( + () => text.Patterns.Text.Pattern.GetVisibleRanges().Any(range => range.GetText(-1).Contains("Latest entry marker", StringComparison.Ordinal)) ? text : null, + "the newest log entry to be visible with Follow enabled"); + AutomationElement notice = session.WaitForElement("LogTruncationNotice"); + Assert.Equal("Older log content omitted", notice.Name); + Assert.False(notice.IsOffscreen); + Assert.False(session.App.HasExited); + + // Optional local review evidence; images are restricted to the test app's window. + string? screenshots = Environment.GetEnvironmentVariable("APPKIT_UITEST_SCREENSHOT_DIR"); + if(!string.IsNullOrWhiteSpace(screenshots)) + { + Directory.CreateDirectory(screenshots); + AutomationElement window = session.TopLevelWindows().Single(w => string.Equals(w.Name, "AppKit Sample — Activity log", StringComparison.Ordinal)); + window.AsWindow().SetForeground(); + Rectangle bounds = window.BoundingRectangle; + using CaptureImage capture = Capture.ElementRectangle(window, new System.Drawing.Rectangle(12, 12, bounds.Width - 24, bounds.Height - 24)); + capture.ToFile(Path.Combine(screenshots, $"LogTail-{kind}.png")); + } + } + + + /// ThemeChoice: is saved. [Theory] [InlineData(SampleKind.WinUI)]