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) <noreply@openai.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
6 files changed
+137
-24
No files matched your search
+3
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Uses physical screen coordinates for UI input and capture, restoring the synchronous test thread afterwards.</summary>
|
||||
internal sealed class DpiAwarenessScope : IDisposable
|
||||
{
|
||||
private readonly nint _previous;
|
||||
|
||||
|
||||
|
||||
/// <summary>Disables DPI virtualization on this thread with per-monitor awareness v2 (Windows 10 1703+).</summary>
|
||||
public DpiAwarenessScope()
|
||||
{
|
||||
_previous = SetThreadDpiAwarenessContext(-4);
|
||||
if(_previous == 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastPInvokeError());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Restores the context on the same thread that ran the synchronous UI test.</summary>
|
||||
public void Dispose() => SetThreadDpiAwarenessContext(_previous);
|
||||
|
||||
|
||||
|
||||
/// <summary>Windows' thread-scoped DPI override, supported by the repository's minimum OS.</summary>
|
||||
[DllImport("user32.dll", ExactSpelling = true, SetLastError = true)]
|
||||
private static extern nint SetThreadDpiAwarenessContext(nint dpiContext);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
/// <summary>Huge logs retain the newest entries and a visible notice even while Follow scrolls to the end.</summary>
|
||||
[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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>ThemeChoice: is saved.</summary>
|
||||
[Theory]
|
||||
[InlineData(SampleKind.WinUI)]
|
||||
|
||||
Reference in new issue
Block a user