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)]