Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
51 changes: 34 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,36 @@ 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);
Comment thread
codemonkeychris marked this conversation as resolved.
Outdated
_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;
}
// Final attempt: longer timeout, no further retry.
WaitForText("FixtureStatus", expected, timeoutMs: 5000);
_currentFixture = name;
}

/// <summary>
Expand Down
174 changes: 174 additions & 0 deletions tests/Reactor.AppTests/Infrastructure/SessionInteractivityGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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)
return SessionInteractivity.Locked;

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();
if (state == SessionInteractivity.Active)
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();
if (state == SessionInteractivity.Active)
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");

// First-writer wins so we capture the originating operation.
if (!File.Exists(path))
{
File.WriteAllText(path,
$"timestamp={DateTimeOffset.Now:O}\n" +
$"state={state}\n" +
$"operation={operation}\n" +
$"pid={Environment.ProcessId}\n");
}
Comment thread
codemonkeychris marked this conversation as resolved.
Outdated
}
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;

[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);
}
}
}
47 changes: 30 additions & 17 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 @@ -59,28 +64,36 @@ public static void AssemblyInit(TestContext context)
// Poll for the app window instead of a fixed sleep
WaitForHostWindow();

// Step 2: Create a Desktop session and find the app window
var desktopOptions = new AppiumOptions();
desktopOptions.AddAdditionalCapability("app", "Root");
desktopOptions.AddAdditionalCapability("deviceName", "WindowsPC");
try
{
// 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 (OpenQA.Selenium.WebDriverException)
{
SessionInteractivityGuard.RecheckAfterWebDriverFailure("TestSession session bootstrap");
throw;
}
}

/// <summary>
Expand Down
Loading