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