diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index ce891b2ab..98ba226f7 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -44,6 +44,25 @@ native architecture. These mounted tests check ordered/unordered wrapping at 240 DIPs, reflow at 600 DIPs, and nested/loose list content and formatting. Keep the existing table and disposal proof in the focused run. +`ReactorTimelineTailProofTests` mounts the production timeline without starting +the app composition root or using Gateway credentials. It alternates cached +240-message and short histories at 360 and 900 DIPs, checking the actual final +text's viewport bounds before any manual scrolling. It also checks appended and +streaming rows, arrivals while reading older messages, and control identity across +token/revision updates while already at the tail. +The native ItemsView and its scroll-controller decorator belong to one session +and timeline generation. Session changes replace them together, so a previous +conversation's recycler and pending bring-into-view target cannot affect the next +conversation. Ordinary renders, history revisions, and streaming updates keep +that control and its scroll-away state. + +```powershell +dotnet test .\tests\OpenClaw.Tray.UITests\OpenClaw.Tray.UITests.csproj -r win-x64 -p:Platform=x64 --filter "FullyQualifiedName~ReactorTimelineTailProofTests|FullyQualifiedName~ReactorMarkdownListProofTests|FullyQualifiedName~ReactorMarkdownTableProofTests|FullyQualifiedName~MountedReactorChatDisposalProofTests" +``` + +Use `win-arm64` and `Platform=ARM64` on native ARM64 hosts. These mounted tests +complement, rather than replace, full-app picker and Gateway-history proof. + Before release or removing the pin, also exercise repeated session switching while a bring-into-view request is pending, using 240 mixed-height messages. Verify that message 240 is actually visible, not just that the current scroll diff --git a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs index 081e31626..e71ea0238 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs @@ -167,6 +167,8 @@ void SetEntryHovered(string entryId, bool isHovered) rows.Count, initialTailRequestKey, displayedTailKey) + // A conversation owns its native recycler and pending bring-into-view target. + .WithKey($"timeline:{props.Timeline.SessionId ?? "none"}|{props.Timeline.TimelineGeneration}") .Grid(column: 0) .AutomationName("Chat messages") .HAlign(HorizontalAlignment.Stretch) diff --git a/tests/OpenClaw.Tray.UITests/ReactorTimelineTailProofTests.cs b/tests/OpenClaw.Tray.UITests/ReactorTimelineTailProofTests.cs new file mode 100644 index 000000000..20e8e140c --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/ReactorTimelineTailProofTests.cs @@ -0,0 +1,288 @@ +using System.Diagnostics; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; +using OpenClaw.Chat; +using OpenClawTray.Chat; +using OpenClawTray.Helpers; +using Windows.Foundation; +using Windows.Graphics; +using Xunit.Abstractions; +using static Microsoft.UI.Reactor.Factories; + +namespace OpenClaw.Tray.UITests; + +[Collection(UICollection.Name)] +public sealed class ReactorTimelineTailProofTests(UIThreadFixture ui, ITestOutputHelper output) +{ + [Theory] + [InlineData(360)] + [InlineData(900)] + public async Task CachedSessionReturn_ShowsActualTailWithoutAutomationOrScrollRepair(int width) + { + await ui.ResetContainerAsync(); + var loaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnLoaded(object sender, RoutedEventArgs args) => loaded.TrySetResult(); + await ui.RunOnUIAsync(() => + { + if (ui.Container.IsLoaded) + { + loaded.TrySetResult(); + return; + } + ui.Container.Loaded += OnLoaded; + }); + try { await loaded.Task.WaitAsync(TimeSpan.FromSeconds(10)); } + finally { await ui.RunOnUIAsync(() => ui.Container.Loaded -= OnLoaded); } + ReactorHostControl? host = null; + XamlControlsResources? controlsResources = null; + UIElement? root = null; + Element? current = null; + var longHistory = Timeline("long", 240); + var otherHistory = Timeline("other", 8); + SizeInt32 originalWindowSize = default; + try + { + await ui.RunOnUIAsync(() => + { + // The programmatic test Application needs native theme resources after startup, + // when its XAML metadata provider is available. Do not substitute dummy brushes. + controlsResources = new XamlControlsResources(); + Application.Current.Resources.MergedDictionaries.Add(controlsResources); + originalWindowSize = ui.TestWindow.AppWindow.Size; + var scale = ui.Container.XamlRoot.RasterizationScale; + ui.TestWindow.AppWindow.Resize(new SizeInt32( + (int)Math.Ceiling((width + 48) * scale), + (int)Math.Ceiling(616 * scale))); + host = new ReactorHostControl + { + Width = width, + Height = 520, + RequestedTheme = ElementTheme.Light, + }; + current = Component(longHistory); + root = host.Reconciler.Mount(current, static () => { }); + host.Content = root; + ui.Container.Children.Add(host); + }); + await AssertVisibleAsync("initial long", 239, "TAIL long 240"); + + for (var cycle = 0; cycle < 10; cycle++) + { + await SwitchAsync(otherHistory, replaceItemsView: true); + await AssertVisibleAsync($"other {cycle}", 7, "TAIL other 8"); + await SwitchAsync(longHistory, replaceItemsView: true); + await AssertVisibleAsync($"returned long {cycle}", 239, "TAIL long 240"); + } + + await ui.RunOnUIAsync(() => VisualTestCapture.CaptureAsync(ui.Container, $"SessionReturn{width}")); + await ui.RunOnUIAsync(() => + { + Assert.IsType(Items().Layout); + Assert.Null(Assert.IsType(Items().ScrollView.Content).TryGetElement(0)); + }); + + var appended = longHistory with + { + Timeline = longHistory.Timeline with + { + Entries = [.. longHistory.Timeline.Entries, new("long-241", ChatTimelineItemKind.Assistant, "TAIL long 241")], + }, + }; + await SwitchAsync(appended, replaceItemsView: false); + await AssertVisibleAsync("following appended row", 240, "TAIL long 241"); + var streaming = appended with + { + Timeline = appended.Timeline with + { + Entries = [.. appended.Timeline.Entries.Take(240), + new("long-241", ChatTimelineItemKind.Assistant, "Streaming text\n\nTAIL long 241", IsStreaming: true)], + }, + }; + await SwitchAsync(streaming, replaceItemsView: false); + await AssertVisibleAsync("following growing row", 240, "TAIL long 241"); + // Only after the natural-return assertions: simulate a reader scrolling away. + await ScrollToFirstAsync(); + await AssertVisibleAsync("reader at first row", 0, "Message 1"); + var scrolledAway = streaming with + { + Timeline = streaming.Timeline with + { + Entries = [.. streaming.Timeline.Entries, new("long-242", ChatTimelineItemKind.Assistant, "TAIL long 242")], + }, + }; + await SwitchAsync(scrolledAway, replaceItemsView: false); + await AssertVisibleAsync("append preserves reader position", 0, "Message 1"); + await ui.RunOnUIAsync(() => Assert.InRange(Items().ScrollView.VerticalOffset, 0, 1)); + + var nextGeneration = longHistory with + { + Timeline = longHistory.Timeline with { TimelineGeneration = 1 }, + }; + await SwitchAsync(nextGeneration, replaceItemsView: true); + await AssertVisibleAsync("new generation", 239, "TAIL long 240"); + await ui.RunOnUIAsync(() => + { + // Identity-only checks: the next session replaces these pending requests. + var tokenUpdate = nextGeneration with + { + Timeline = nextGeneration.Timeline with { ScrollToBottomToken = 1 }, + }; + Reconcile(tokenUpdate, replaceItemsView: false); + Reconcile(tokenUpdate with { HistoryRevision = 2 }, replaceItemsView: false); + Reconcile(otherHistory, replaceItemsView: true); + Reconcile(longHistory, replaceItemsView: true); + }); + await AssertVisibleAsync("return before previous layout", 239, "TAIL long 240"); + } + finally + { + await ui.RunOnUIAsync(() => + { + if (host is not null) + { + if (current is not null && root is not null) + host.Content = host.Reconciler.Reconcile(current, Empty(), root, static () => { }); + ui.Container.Children.Remove(host); + host.Content = null; + host.Dispose(); + } + if (controlsResources is not null) + Application.Current.Resources.MergedDictionaries.Remove(controlsResources); + if (originalWindowSize.Width > 0) + ui.TestWindow.AppWindow.Resize(originalWindowSize); + }); + } + + ItemsView Items() => Descendants(host!).Single(); + + async Task ScrollToFirstAsync() + { + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var correlationId = -1; + ScrollView? scroll = null; + void OnCompleted(ScrollView sender, ScrollingScrollCompletedEventArgs args) + { + if (correlationId == args.CorrelationId) + completed.TrySetResult(); + } + try + { + await ui.RunOnUIAsync(() => + { + scroll = Items().ScrollView; + scroll.ScrollCompleted += OnCompleted; + correlationId = scroll.ScrollTo(0, 0, new ScrollingScrollOptions( + ScrollingAnimationMode.Disabled, ScrollingSnapPointsMode.Ignore)); + Assert.True(correlationId >= 0, "The reader scroll request was not accepted."); + }); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + finally + { + await ui.RunOnUIAsync(() => + { + if (scroll is not null) + scroll.ScrollCompleted -= OnCompleted; + }); + } + } + + Task SwitchAsync(ReactorChatTimelineProps next, bool replaceItemsView) => + ui.RunOnUIAsync(() => Reconcile(next, replaceItemsView)); + + void Reconcile(ReactorChatTimelineProps next, bool replaceItemsView) + { + var previousItemsView = TestSupport.FindLogical(root!).Single(); + var nextElement = Component(next); + root = host!.Reconciler.Reconcile(current!, nextElement, root!, static () => { }); + current = nextElement; + host.Content = root; + // Read the logical tree before the next layout attaches a newly mounted control. + var nextItemsView = TestSupport.FindLogical(root!).Single(); + if (replaceItemsView) + Assert.NotSame(previousItemsView, nextItemsView); + else + Assert.Same(previousItemsView, nextItemsView); + } + + async Task AssertVisibleAsync(string phase, int index, string marker) + { + var deadline = Stopwatch.StartNew(); + var visible = false; + var diagnostics = ""; + while (deadline.Elapsed < TimeSpan.FromSeconds(10)) + { + await ui.YieldToRenderAsync().WaitAsync(TimeSpan.FromSeconds(15)); + await ui.RunOnUIAsync(() => + { + var items = Descendants(host!).SingleOrDefault(); + if (items is null) + return; + Assert.Same( + TestSupport.FindLogical(root!).Single().ScrollController, + items.VerticalScrollController); + var scroll = items.ScrollView; + var repeater = Descendants(items).SingleOrDefault(); + var row = repeater?.TryGetElement(index) as FrameworkElement; + var bounds = row is not null && scroll is not null + ? row.TransformToVisual(scroll).TransformBounds(new Rect(0, 0, row.ActualWidth, row.ActualHeight)) + : default; + var markers = row is null ? [] : + Descendants(row) + .Where(block => block.Text.Contains(marker, StringComparison.Ordinal)) + .Cast() + .Concat(Descendants(row) + .Where(block => CollectText(block).Contains(marker, StringComparison.Ordinal))) + .ToArray(); + visible = row is { ActualHeight: > 0, Visibility: Visibility.Visible } && scroll is not null + && markers.Any(block => + { + var textBounds = block.TransformToVisual(scroll).TransformBounds( + new Rect(0, 0, block.ActualWidth, block.ActualHeight)); + return block.ActualHeight > 0 && textBounds.Top >= -1 + && textBounds.Bottom <= scroll.ActualHeight + 1; + }); + diagnostics = $"{phase}: offset={scroll?.VerticalOffset}, extent={scroll?.ExtentHeight}, " + + $"viewport={scroll?.ActualHeight}, row={bounds}, marker={marker}, visible={visible}"; + }).WaitAsync(TimeSpan.FromSeconds(15)); + if (visible) + break; + } + output.WriteLine(diagnostics); + Assert.True(visible, diagnostics); + } + } + + private static string CollectText(RichTextBlock block) => string.Concat( + block.Blocks.OfType().SelectMany(paragraph => paragraph.Inlines.OfType()) + .Select(run => run.Text)); + + private static ReactorChatTimelineProps Timeline(string session, int count) + { + var entries = Enumerable.Range(1, count).Select(index => new ChatTimelineItem( + $"{session}-{index}", + index % 2 == 0 ? ChatTimelineItemKind.Assistant : ChatTimelineItemKind.User, + index == count ? $"TAIL {session} {count}" : + index % 4 == 0 ? $"Message {index}\n\n- First item with enough words to wrap at a narrow width.\n- Second item\n\nEnd." + : index % 4 == 2 ? $"Message {index}\n\nItem | State\n---|---\nCopper telescope | Parked\nViolet compass | Ready\n\nA small synthetic table." + : $"Message {index}\n\nA longer paragraph for mixed-height virtualization. The quiet observatory has three windows and a copper telescope. All names and events are fictional." + )).ToArray(); + return new(ReactorChatTimelineMode.Timeline, new(session, entries, false, null), HistoryRevision: 1); + } + + private static IEnumerable Descendants(DependencyObject root) where T : DependencyObject + { + for (var index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is T match) + yield return match; + foreach (var descendant in Descendants(child)) + yield return descendant; + } + } +} diff --git a/tests/OpenClaw.Tray.UITests/TestApp.cs b/tests/OpenClaw.Tray.UITests/TestApp.cs index 5e4c48a72..8f8ad82e9 100644 --- a/tests/OpenClaw.Tray.UITests/TestApp.cs +++ b/tests/OpenClaw.Tray.UITests/TestApp.cs @@ -22,8 +22,16 @@ namespace OpenClaw.Tray.UITests; /// visual tree even before the merge happens — assertions on text content, /// hierarchy, and click handlers don't depend on theme styles. /// -internal sealed class TestApp : Application +internal sealed class TestApp : Application, IXamlMetadataProvider { + // Resolve the same native XAML types as the app without constructing its composition root. + private readonly IXamlMetadataProvider _metadata = + new OpenClawTray.OpenClaw_Tray_WinUI_XamlTypeInfo.XamlMetaDataProvider(); + + public IXamlType GetXamlType(Type type) => _metadata.GetXamlType(type); + public IXamlType GetXamlType(string fullName) => _metadata.GetXamlType(fullName); + public XmlnsDefinition[] GetXmlnsDefinitions() => _metadata.GetXmlnsDefinitions(); + private static readonly (string Key, Windows.UI.Color Color)[] FluentBrushFallbacks = [ ("SolidBackgroundFillColorBaseBrush", Colors.White),