diff --git a/build/Test-InstalledUpdates.ps1 b/build/Test-InstalledUpdates.ps1 index a1b2404..cd83751 100644 --- a/build/Test-InstalledUpdates.ps1 +++ b/build/Test-InstalledUpdates.ps1 @@ -185,7 +185,7 @@ try if($loose.Installed -or $loose.AppKitSupported -or $null -ne $loose.Version) { throw 'Loose publish output unexpectedly supports updates.' } $records.Add(@{ kind = 'loose'; result = $loose }) $env:K22_UITEST_EXE = Join-Path $buildRoot "publish/0.1.0/$mainExe" - Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--logger', 'trx;LogFileName=published-ui.trx', '--results-directory', $outputRoot) -TimeoutSeconds 300 + Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--logger', 'trx;LogFileName=published-ui.trx', '--results-directory', $outputRoot) -TimeoutSeconds 600 foreach($initial in @($versions[0], $versions[2])) { $portableDirectory = Join-Path $outputRoot "portable-$($initial.channel)" @@ -236,7 +236,7 @@ try $after = Get-SmokeResult -Exe $installedProbe -ResultPath (Join-Path $outputRoot "$channel-after.json") if($after.Version -ne $initial.target -or $after.Channel -ne $channel -or $null -ne $after.UpdateVersion -or $after.AppKitVersion -ne $initial.target) { throw 'Post-upgrade identity or channel is incorrect.' } $env:K22_UITEST_EXE = $currentExe - Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--filter', 'FullyQualifiedName!~CheckForUpdates_InALooseBuild', '--logger', "trx;LogFileName=installed-$channel-ui.trx", '--results-directory', $outputRoot) -TimeoutSeconds 300 + Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--filter', 'FullyQualifiedName!~CheckForUpdates_InALooseBuild', '--logger', "trx;LogFileName=installed-$channel-ui.trx", '--results-directory', $outputRoot) -TimeoutSeconds 600 $records.Add(@{ kind = 'installed'; channel = $channel; before = $before; after = $after; restarted = $true; appSha256 = $installedHash }) } finally diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 2bc8a49..e127223 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -76,7 +76,19 @@ protects sibling settings and cleared history from stale window snapshots. Signed/DCOe48a08c implements this milestone. Local376 core tests/100% lines and zero-warning build/format/docs/hooks pass. Three new hosted-only native lifecycle cases compile; no local GUI input. ADR-0004 and threat model -updated. These tests have not run. Association/activation registration remains +updated. 1cfc07f / CI38129552334 native54/57, zero skips: all original54 pass, +three new cases fail. First caches a glyph across zoom navigation; second requests +unsupported Invoke from SourceToggle after actual New/Recent/Close/missing/clear +steps; third cannot find Open picker's filename field. Fresh glyph lookup and +verified owned physical menu input retain all state/reload/history assertions. +Picker diagnostics now retain owned tree and generic exception/HRESULT; no selector +change is guessed. Actual native foreground focus and source preference persistence are required. +The missing-file window loaded nonempty history before Clear; its later preference +save must not resurrect that snapshot. +Retain native red log: artifacts/windows-first-native-38129552334.log. Published process timed out at300seconds after two recorded new-case failures; +no TRX/installed-channel outcomes. Retained partial output/screenshots: +artifacts/windows-first-failed-installed-38129552334. Outer full-UI-suite budget +now600seconds; all individual10/30second waits/assertions unchanged. Association/activation registration remains required. Full reference audit also retains theme ordering/enable/delete/cycle, JSON import/export conflicts, all typography/global CSS/content width and four configurable rendering extensions in PRODUCT/TODO; none is claimed complete. diff --git a/src/MarkdownViewr.Windows/MainWindow.xaml.cs b/src/MarkdownViewr.Windows/MainWindow.xaml.cs index f310122..29f9b50 100644 --- a/src/MarkdownViewr.Windows/MainWindow.xaml.cs +++ b/src/MarkdownViewr.Windows/MainWindow.xaml.cs @@ -171,7 +171,7 @@ public sealed partial class MainWindow : Window, IDisposable if(!_closed) { StatusText.Text = "The file picker could not complete. Please try again."; - App.Log.Warning("The native document picker could not complete."); + App.Log.Warning(FormattableString.Invariant($"The native document picker could not complete. Exception={exception.GetType().Name}; HRESULT=0x{exception.HResult:X8}.")); } } } diff --git a/tests/MarkdownViewr.Windows.UiTests/AppTests.Windows.cs b/tests/MarkdownViewr.Windows.UiTests/AppTests.Windows.cs index b62bca8..da7a409 100644 --- a/tests/MarkdownViewr.Windows.UiTests/AppTests.Windows.cs +++ b/tests/MarkdownViewr.Windows.UiTests/AppTests.Windows.cs @@ -3,11 +3,13 @@ * SPDX-License-Identifier: MIT */ +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; using FlaUI.Core.Input; +using FlaUI.Core.Patterns; using FlaUI.Core.WindowsAPI; @@ -31,7 +33,11 @@ public partial class AppTests Assert.Equal(2, DocumentWindows(session).Length); first.SetForeground(); WaitForWindowElement(session, first, "ZoomInButton").Patterns.Invoke.Pattern.Invoke(); - session.WaitFor(() => firstHeading.BoundingRectangle.Height > firstHeight && WaitForWindowElement(session, first, "ZoomResetButton").IsEnabled ? firstHeading : null, "the first window's actual larger glyph"); + session.WaitFor(() => + { + AutomationElement? current = first.FindFirstDescendant(cf => cf.ByAutomationId("mv-heading-0")); + return current is not null && current.BoundingRectangle.Height > firstHeight && WaitForWindowElement(session, first, "ZoomResetButton").IsEnabled ? current : null; + }, "the first window's actual larger glyph after its document navigation"); Assert.Equal(secondHeight, secondHeading.BoundingRectangle.Height); Assert.Equal("Actual size (100%)", WaitForWindowElement(session, second, "ZoomResetButton").Name); first.SetForeground(); @@ -68,6 +74,7 @@ public partial class AppTests Assert.Null(blank.FindFirstDescendant(cf => cf.ByAutomationId("mv-heading-0"))); Assert.Equal(3, DocumentWindows(session).Length); InvokeDocumentMenu(session, blank, "RecentDocument:" + Array.IndexOf(history, Path.Combine(session.DataDirectory, "one.md")), recent: true); + session.WaitFor(() => GetForegroundWindow() == first.Properties.NativeWindowHandle.Value ? first : null, "the actual original reader brought to the native foreground by its recent entry"); Assert.Equal(3, DocumentWindows(session).Length); Assert.Equal(history, ReadRecentDocuments(session)); InvokeDocumentMenu(session, blank, "CloseWindowMenuItem"); @@ -81,9 +88,14 @@ public partial class AppTests Assert.Equal(history, ReadRecentDocuments(session)); InvokeDocumentMenu(session, missing, "ClearRecentDocuments", recent: true); Assert.Empty(ReadRecentDocuments(session)); - InvokeDocumentMenu(session, first, "SourceToggle", menuName: "View"); - session.WaitFor(() => first.FindFirstDescendant(cf => cf.ByAutomationId("SourceText")), "the first window saving its source preference"); + // This window loaded the nonempty history before Clear; its next preference save must not resurrect that stale snapshot. + InvokeDocumentMenu(session, missing, "SourceToggle", menuName: "View"); + session.WaitFor(() => missing.FindFirstDescendant(cf => cf.ByAutomationId("SourceText")), "the older-history window saving its source preference after Clear"); Assert.Empty(ReadRecentDocuments(session)); + using(var saved = JsonDocument.Parse(session.ReadSettings())) + { + Assert.True(saved.RootElement.GetProperty("ShowSource").GetBoolean()); + } Assert.True(File.Exists(Path.Combine(session.DataDirectory, "one.md"))); RetainDocumentWindow(session, first, "cleared-history-reader"); InvokeDocumentMenu(session, first, "ExitMenuItem"); @@ -105,7 +117,7 @@ public partial class AppTests Window original = WaitForDocumentWindow(session, "original.txt"); WaitForWindowHeading(session, original, "Original picker reader"); InvokeDocumentMenu(session, original, "OpenMenuItem"); - AutomationElement filename = session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("1001").And(cf.ByControlType(ControlType.Edit))), "the real Windows multi-file picker filename field"); + AutomationElement filename = WaitForOpenPickerFilename(session); string paths = "\"" + Path.Combine(session.DataDirectory, "picked & caf\u00e9.mdown") + "\" \"" + Path.Combine(session.DataDirectory, "picked two.mkd") + "\""; EnterPickerFilename(session, filename, paths); session.ClickButton("Open"); @@ -118,7 +130,7 @@ public partial class AppTests string[] history = ReadRecentDocuments(session); Assert.Equal(3, history.Length); InvokeDocumentMenu(session, original, "OpenMenuItem"); - session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("1001").And(cf.ByControlType(ControlType.Edit))), "the reopened native file picker"); + WaitForOpenPickerFilename(session); session.ClickButton("Cancel"); session.WaitForElementGone("1001"); Assert.Equal(3, DocumentWindows(session).Length); @@ -129,6 +141,12 @@ public partial class AppTests + /// Observes actual native foreground ownership without relying on a mirrored application property. + [LibraryImport("user32.dll")] + private static partial nint GetForegroundWindow(); + + + /// Counts only real app document windows, excluding native menu, log and picker surfaces. private static Window[] DocumentWindows(AppSession session) => session.TopLevelWindows().Where(window => window.FindFirstDescendant(cf => cf.ByAutomationId("DocumentView")) is not null).Select(window => window.AsWindow()).ToArray(); @@ -174,12 +192,54 @@ public partial class AppTests submenu.Patterns.ExpandCollapse.Pattern.Expand(); } AutomationElement item = session.WaitFor(() => session.TopLevelWindows().Select(root => root.FindFirstDescendant(cf => cf.ByAutomationId(id))).FirstOrDefault(element => element is not null), "the requested native " + id + " command"); - item.Patterns.Invoke.Pattern.Invoke(); + if(item.Patterns.Invoke.TryGetPattern(out IInvokePattern? invoke)) + { + invoke.Invoke(); + } + else + { + Assert.True(item.TryGetClickablePoint(out System.Drawing.Point point)); + NativeMouse.MoveTo(point); + Wait.UntilInputIsProcessed(); + AutomationElement? hit = session.Automation.FromPoint(Mouse.Position); + bool target = false; + for(int depth = 0; hit is not null && depth < 8; depth++, hit = hit.Parent) + { + if(hit.Equals(item)) + { + target = true; + break; + } + } + Assert.True(target, "The actual native pointer must be over the requested menu item or its child."); + Assert.Equal(session.App.ProcessId, session.Automation.FromPoint(Mouse.Position).Properties.ProcessId.ValueOrDefault); + Mouse.Down(MouseButton.Left); + Wait.UntilInputIsProcessed(); + Mouse.Up(MouseButton.Left); + } Wait.UntilInputIsProcessed(); } + /// Retains actual owned native picker controls and nonsensitive app errors when the exact filename lookup fails. + private static AutomationElement WaitForOpenPickerFilename(AppSession session) + { + try + { + return session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("1001").And(cf.ByControlType(ControlType.Edit))), "the real Windows multi-file picker filename field"); + } + catch(TimeoutException exception) + { + string windows = string.Join(Environment.NewLine, session.TopLevelWindows().Select(window => + "Window=" + window.Name + "; " + string.Join(Environment.NewLine, window.FindAllDescendants().Take(180).Select(element => + "Id=" + element.Properties.AutomationId.ValueOrDefault + "; Name=" + element.Properties.Name.ValueOrDefault + "; Type=" + element.Properties.ControlType.ValueOrDefault)))); + throw new TimeoutException(exception.Message + " Logs: " + session.ReadLogs() + "; Owned native windows: " + windows + "; Main tree: " + session.DescribeDocumentStatus(), exception); + } + } + + + /// Retains only the owned fixture window; hosted installed evidence collects these actual pixels. private static void RetainDocumentWindow(AppSession session, Window window, string name) {