Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 31 additions & 17 deletions tests/Reactor.AppTests/Infrastructure/AppTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ public class AppTestBase
/// </summary>
protected static WindowsDriver<WindowsElement> Session => TestSession.Session;

// No TestInitialize/TestCleanup — NavigateToFixture handles fixture switching.
// Resetting between every test wastes ~5s on the implicit wait timeout.
// Per-test interactivity preflight — bails out as Inconclusive (not Failed)
// when the workstation is locked or the session is disconnected, so flake
// reports don't drown in environmental noise.
[TestInitialize]
public void GuardSessionInteractive()
{
SessionInteractivityGuard.EnsureInteractive("TestInitialize");
}

private static string? _currentFixture;

Expand All @@ -40,25 +46,33 @@ protected void NavigateToFixture(string name)
// navigator's hit-test rebuild), the wait times out — retry the click
// once before giving up. This keeps fast paths fast (no extra waits in
// the common case) but absorbs the occasional missed click.
for (int attempt = 0; attempt < 2; attempt++)
try
{
Session.FindElement(MobileBy.AccessibilityId($"Nav_{name}")).Click();
try
{
WaitForText("FixtureStatus", expected, timeoutMs: 5000);
_currentFixture = name;
return;
}
catch (WebDriverTimeoutException) when (attempt == 0)
for (int attempt = 0; attempt < 2; attempt++)
{
// Brief pause before the retry so the next click doesn't land in
// the same window that swallowed the first one.
Thread.Sleep(250);
Session.FindElement(MobileBy.AccessibilityId($"Nav_{name}")).Click();
try
{
WaitForText("FixtureStatus", expected, timeoutMs: 5000);
_currentFixture = name;
return;
}
catch (WebDriverTimeoutException) when (attempt == 0)
{
// Brief pause before the retry so the next click doesn't land in
// the same window that swallowed the first one.
Thread.Sleep(250);
}
}
}
// Final attempt: longer timeout, no further retry.
WaitForText("FixtureStatus", expected, timeoutMs: 5000);
_currentFixture = name;
catch (WebDriverException)
{
// The screen may have locked between the preflight check and the click.
// Recheck — if locked, surface as Inconclusive; otherwise rethrow as a
// real test failure.
SessionInteractivityGuard.RecheckAfterWebDriverFailure($"NavigateToFixture({name})");
throw;
}
}

/// <summary>
Expand Down
204 changes: 204 additions & 0 deletions tests/Reactor.AppTests/Infrastructure/SessionInteractivityGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
using System.Runtime.InteropServices;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Microsoft.UI.Reactor.AppTests.Infrastructure;

public enum SessionInteractivity
{
Active,
Locked,
Disconnected,
Unknown,
}

/// <summary>
/// Detects when the test process can no longer drive the desktop — workstation
/// locked, idle/timeout lock, or RDP/console disconnect. These conditions cause
/// every Click/SendKeys to fail with a generic WebDriverException, masquerading
/// as test flake. We surface them as Inconclusive (not Failed) and write a
/// marker file so the loop runner can abort the rest of the run.
/// </summary>
public static class SessionInteractivityGuard
{
public const string MarkerEnvVar = "E2E_LOCK_MARKER_PATH";

public static SessionInteractivity GetState()
{
if (TryGetConnectState(out var wtsState) && wtsState != WTSActive)
return SessionInteractivity.Disconnected;

var hDesktop = OpenInputDesktop(0, false, DESKTOP_READOBJECTS);
if (hDesktop == IntPtr.Zero)
{
// Read GetLastError before any other call can clobber it.
// ERROR_ACCESS_DENIED is the documented signal that the calling
// thread can't access the active input desktop — what happens when
// Winlogon's secure desktop is up. Other failures (invalid handle,
// out of memory, transient) are genuinely Unknown — don't tag them
// Locked, or real test failures get masked as Inconclusive.
var err = Marshal.GetLastWin32Error();
return err == ERROR_ACCESS_DENIED
? SessionInteractivity.Locked
: SessionInteractivity.Unknown;
}

Comment thread
codemonkeychris marked this conversation as resolved.
try
{
GetUserObjectInformation(hDesktop, UOI_NAME, IntPtr.Zero, 0, out var needed);
if (needed == 0)
return SessionInteractivity.Unknown;

var buf = Marshal.AllocHGlobal((int)needed);
try
{
if (!GetUserObjectInformation(hDesktop, UOI_NAME, buf, needed, out _))
return SessionInteractivity.Unknown;
var name = Marshal.PtrToStringUni(buf) ?? string.Empty;
return string.Equals(name, "Default", StringComparison.OrdinalIgnoreCase)
? SessionInteractivity.Active
: SessionInteractivity.Locked;
}
finally
{
Marshal.FreeHGlobal(buf);
}
}
finally
{
CloseDesktop(hDesktop);
}
}

/// <summary>
/// Throws <see cref="AssertInconclusiveException"/> with a clear message and
/// writes a marker file if the session is not Active. The test framework
/// records the outcome as Inconclusive (not Failed), and the loop runner sees
/// the marker and stops scheduling further iterations.
/// </summary>
public static void EnsureInteractive(string operation)
{
var state = GetState();
// Unknown means the OS gave us an unexpected error from the desktop
// probe — don't fabricate a verdict. Let the test run; if WinAppDriver
// really can't drive input, the WebDriverException recheck will catch
// a definite Locked/Disconnected on the second look.
if (state == SessionInteractivity.Active || state == SessionInteractivity.Unknown)
return;

WriteMarker(state, operation);
Assert.Inconclusive(
$"Cannot perform '{operation}': workstation is {state}. " +
"UI automation needs an active interactive desktop — locked screen, " +
"idle/sleep lock, or RDP disconnect makes every WinAppDriver Click() " +
"fail with a generic WebDriverException. Treating these as Inconclusive " +
"(not Failed). Unlock the session and rerun.");
}

/// <summary>
/// If <paramref name="operation"/> threw a <see cref="OpenQA.Selenium.WebDriverException"/>,
/// recheck interactivity and turn the failure into Inconclusive when the screen
/// has locked since the operation started. Otherwise rethrows the original.
/// </summary>
public static void RecheckAfterWebDriverFailure(string operation)
{
var state = GetState();
// Only reclassify when we have positive evidence the desktop is
// unreachable. Active and Unknown both fall through and the original
// WebDriverException is rethrown — masking a real failure as
// Inconclusive on Unknown would lose signal in the diagnostic loop
// we built this for.
if (state == SessionInteractivity.Active || state == SessionInteractivity.Unknown)
return; // Real test failure — caller should rethrow.

WriteMarker(state, operation);
Assert.Inconclusive(
$"'{operation}' failed and the workstation is now {state}. " +
"The failure is environmental (locked desktop / disconnected session), " +
"not a test bug. Marker written; remaining tests will short-circuit.");
}

private static void WriteMarker(SessionInteractivity state, string operation)
{
try
{
var path = Environment.GetEnvironmentVariable(MarkerEnvVar);
if (string.IsNullOrEmpty(path))
path = Path.Combine(Path.GetTempPath(), "reactor_e2e_session_locked.flag");

// FileMode.CreateNew is atomic — first writer wins under parallel
// contention, and a stale marker from a previous loop won't get
// silently overwritten with a misleading new timestamp. The runner
// is responsible for clearing the path between iterations (it
// points at a fresh per-run directory each time).
var bytes = System.Text.Encoding.UTF8.GetBytes(
$"timestamp={DateTimeOffset.Now:O}\n" +
$"state={state}\n" +
$"operation={operation}\n" +
$"pid={Environment.ProcessId}\n");
using var fs = new FileStream(
path, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
fs.Write(bytes, 0, bytes.Length);
}
catch (IOException)
{
// Marker already exists — first writer won. Their state/operation
// is what we want to preserve, so don't overwrite.
}
catch
{
// Best-effort — never let marker writing mask the real signal.
}
}

// ─── P/Invoke ────────────────────────────────────────────────────────────

private const uint DESKTOP_READOBJECTS = 0x0001;
private const int UOI_NAME = 2;
private const int ERROR_ACCESS_DENIED = 5;

[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr OpenInputDesktop(uint dwFlags, bool fInherit, uint dwDesiredAccess);

[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseDesktop(IntPtr hDesktop);

[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetUserObjectInformation(
IntPtr hObj, int nIndex, IntPtr pvInfo, uint nLength, out uint lpnLengthNeeded);

private const int WTS_CURRENT_SESSION = -1;
private const int WTSConnectState_InfoClass = 8;
private const int WTSActive = 0;
private static readonly IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;

[DllImport("wtsapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WTSQuerySessionInformation(
IntPtr hServer, int sessionId, int infoClass,
out IntPtr ppBuffer, out int pBytesReturned);

[DllImport("wtsapi32.dll")]
private static extern void WTSFreeMemory(IntPtr pMemory);

private static bool TryGetConnectState(out int state)
{
state = WTSActive;
if (!WTSQuerySessionInformation(
WTS_CURRENT_SERVER_HANDLE, WTS_CURRENT_SESSION,
WTSConnectState_InfoClass, out var buf, out _))
{
return false;
}
try
{
state = Marshal.ReadInt32(buf);
return true;
}
finally
{
WTSFreeMemory(buf);
}
}
}
56 changes: 37 additions & 19 deletions tests/Reactor.AppTests/Infrastructure/TestSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ public static void AssemblyInit(TestContext context)
return;
}

// Bail out cleanly if the desktop is already locked / disconnected.
// Without this, we'd spend minutes booting WinAppDriver + the host app,
// only to fail every test on the first Click() with a generic error.
SessionInteractivityGuard.EnsureInteractive("TestSession.AssemblyInit");

Comment thread
codemonkeychris marked this conversation as resolved.
// Kill any orphaned processes from a previous failed run
KillOrphanedProcesses();

Expand All @@ -56,31 +61,44 @@ public static void AssemblyInit(TestContext context)
});
Console.WriteLine($"Host app launched (PID {_appProcess?.Id}).");

// Poll for the app window instead of a fixed sleep
WaitForHostWindow();
try
{
// Poll for the app window instead of a fixed sleep. WaitForHostWindow
// throws TimeoutException after swallowing per-poll WebDriverExceptions —
// a mid-init screen lock surfaces here, not as a WebDriverException.
WaitForHostWindow();

// Step 2: Create a Desktop session and find the app window
var desktopOptions = new AppiumOptions();
desktopOptions.AddAdditionalCapability("app", "Root");
desktopOptions.AddAdditionalCapability("deviceName", "WindowsPC");
// Step 2: Create a Desktop session and find the app window
var desktopOptions = new AppiumOptions();
desktopOptions.AddAdditionalCapability("app", "Root");
desktopOptions.AddAdditionalCapability("deviceName", "WindowsPC");

using var desktopSession = new WindowsDriver<WindowsElement>(
new Uri(WinAppDriverUrl), desktopOptions);
using var desktopSession = new WindowsDriver<WindowsElement>(
new Uri(WinAppDriverUrl), desktopOptions);

// Find the Host app window by title
var appWindow = desktopSession.FindElementByName("Reactor Test Host");
var appWindowHandle = appWindow.GetAttribute("NativeWindowHandle");
var hwnd = int.Parse(appWindowHandle).ToString("x"); // hex for WinAppDriver
// Find the Host app window by title
var appWindow = desktopSession.FindElementByName("Reactor Test Host");
var appWindowHandle = appWindow.GetAttribute("NativeWindowHandle");
var hwnd = int.Parse(appWindowHandle).ToString("x"); // hex for WinAppDriver

// Step 3: Create a session attached to the app window
var appOptions = new AppiumOptions();
appOptions.AddAdditionalCapability("appTopLevelWindow", $"0x{hwnd}");
appOptions.AddAdditionalCapability("deviceName", "WindowsPC");
// Step 3: Create a session attached to the app window
var appOptions = new AppiumOptions();
appOptions.AddAdditionalCapability("appTopLevelWindow", $"0x{hwnd}");
appOptions.AddAdditionalCapability("deviceName", "WindowsPC");

_session = new WindowsDriver<WindowsElement>(new Uri(WinAppDriverUrl), appOptions);
_session.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(2);
_session = new WindowsDriver<WindowsElement>(new Uri(WinAppDriverUrl), appOptions);
_session.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(2);

Console.WriteLine("WindowsDriver session attached to Host app.");
Console.WriteLine("WindowsDriver session attached to Host app.");
}
catch (Exception ex) when (ex is OpenQA.Selenium.WebDriverException || ex is TimeoutException)
{
// Catches both: WebDriverException from the session steps, and
// TimeoutException from WaitForHostWindow. Either could mask a
// workstation lock that happened after the AssemblyInit preflight.
SessionInteractivityGuard.RecheckAfterWebDriverFailure("TestSession session bootstrap");
throw;
}
}

/// <summary>
Expand Down
Loading