Skip to content

Commit 6d9fd00

Browse files
fix(apptests): detect locked desktop / disconnected session (#164)
* fix(apptests): detect locked desktop / disconnected session E2E runs against WinAppDriver fail every Click() when the workstation locks (idle timeout, manual lock, RDP disconnect) — surfacing as a flood of WebDriverException "An unknown error occurred in the remote end" that's indistinguishable from real test flake. Observed in repeated runs where 5+ consecutive iterations failed 48/57 tests with identical stack traces, then recovered on next iteration once the desktop was unlocked. Add SessionInteractivityGuard to query OpenInputDesktop (locked screen switches the input desktop from "Default" to "Winlogon") and WTSConnectState (RDP/console connect state). On detection: - Emit Assert.Inconclusive instead of letting the test Fail, so the .trx outcome distinguishes environmental from real failures. - Write a marker file at \$E2E_LOCK_MARKER_PATH so external runners can abort the rest of a multi-iteration loop instead of generating more false-negative results. Hooks: [TestInitialize] in AppTestBase preflights every test, plus a WebDriverException recheck in NavigateToFixture and TestSession session bootstrap to catch locks that happen mid-operation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(apptests): address CR feedback on lock-detection guard - AppTestBase: drop dead "final attempt" line + misleading comment. The for-loop's `when (attempt == 0)` filter meant iteration 1's WebDriverTimeoutException always propagated out, making the post-loop WaitForText unreachable. - SessionInteractivityGuard: only treat OpenInputDesktop returning NULL as Locked when GetLastWin32Error is ERROR_ACCESS_DENIED. Other failure codes are Unknown and don't trigger Inconclusive — avoids masking real test failures behind transient Win32 errors. - SessionInteractivityGuard: WriteMarker uses FileMode.CreateNew for an atomic first-writer-wins, replacing the racy File.Exists+WriteAllText pattern. Stale markers still won't get overwritten silently. - TestSession: extend the bootstrap try/catch to cover WaitForHostWindow too, and handle TimeoutException as well as WebDriverException — a mid-init lock surfaces as a TimeoutException from the polling loop, not as a WebDriverException. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bfc0b9b commit 6d9fd00

3 files changed

Lines changed: 272 additions & 36 deletions

File tree

tests/Reactor.AppTests/Infrastructure/AppTestBase.cs

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@ public class AppTestBase
1818
/// </summary>
1919
protected static WindowsDriver<WindowsElement> Session => TestSession.Session;
2020

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

2430
private static string? _currentFixture;
2531

@@ -40,25 +46,33 @@ protected void NavigateToFixture(string name)
4046
// navigator's hit-test rebuild), the wait times out — retry the click
4147
// once before giving up. This keeps fast paths fast (no extra waits in
4248
// the common case) but absorbs the occasional missed click.
43-
for (int attempt = 0; attempt < 2; attempt++)
49+
try
4450
{
45-
Session.FindElement(MobileBy.AccessibilityId($"Nav_{name}")).Click();
46-
try
47-
{
48-
WaitForText("FixtureStatus", expected, timeoutMs: 5000);
49-
_currentFixture = name;
50-
return;
51-
}
52-
catch (WebDriverTimeoutException) when (attempt == 0)
51+
for (int attempt = 0; attempt < 2; attempt++)
5352
{
54-
// Brief pause before the retry so the next click doesn't land in
55-
// the same window that swallowed the first one.
56-
Thread.Sleep(250);
53+
Session.FindElement(MobileBy.AccessibilityId($"Nav_{name}")).Click();
54+
try
55+
{
56+
WaitForText("FixtureStatus", expected, timeoutMs: 5000);
57+
_currentFixture = name;
58+
return;
59+
}
60+
catch (WebDriverTimeoutException) when (attempt == 0)
61+
{
62+
// Brief pause before the retry so the next click doesn't land in
63+
// the same window that swallowed the first one.
64+
Thread.Sleep(250);
65+
}
5766
}
5867
}
59-
// Final attempt: longer timeout, no further retry.
60-
WaitForText("FixtureStatus", expected, timeoutMs: 5000);
61-
_currentFixture = name;
68+
catch (WebDriverException)
69+
{
70+
// The screen may have locked between the preflight check and the click.
71+
// Recheck — if locked, surface as Inconclusive; otherwise rethrow as a
72+
// real test failure.
73+
SessionInteractivityGuard.RecheckAfterWebDriverFailure($"NavigateToFixture({name})");
74+
throw;
75+
}
6276
}
6377

6478
/// <summary>
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
using System.Runtime.InteropServices;
2+
using Microsoft.VisualStudio.TestTools.UnitTesting;
3+
4+
namespace Microsoft.UI.Reactor.AppTests.Infrastructure;
5+
6+
public enum SessionInteractivity
7+
{
8+
Active,
9+
Locked,
10+
Disconnected,
11+
Unknown,
12+
}
13+
14+
/// <summary>
15+
/// Detects when the test process can no longer drive the desktop — workstation
16+
/// locked, idle/timeout lock, or RDP/console disconnect. These conditions cause
17+
/// every Click/SendKeys to fail with a generic WebDriverException, masquerading
18+
/// as test flake. We surface them as Inconclusive (not Failed) and write a
19+
/// marker file so the loop runner can abort the rest of the run.
20+
/// </summary>
21+
public static class SessionInteractivityGuard
22+
{
23+
public const string MarkerEnvVar = "E2E_LOCK_MARKER_PATH";
24+
25+
public static SessionInteractivity GetState()
26+
{
27+
if (TryGetConnectState(out var wtsState) && wtsState != WTSActive)
28+
return SessionInteractivity.Disconnected;
29+
30+
var hDesktop = OpenInputDesktop(0, false, DESKTOP_READOBJECTS);
31+
if (hDesktop == IntPtr.Zero)
32+
{
33+
// Read GetLastError before any other call can clobber it.
34+
// ERROR_ACCESS_DENIED is the documented signal that the calling
35+
// thread can't access the active input desktop — what happens when
36+
// Winlogon's secure desktop is up. Other failures (invalid handle,
37+
// out of memory, transient) are genuinely Unknown — don't tag them
38+
// Locked, or real test failures get masked as Inconclusive.
39+
var err = Marshal.GetLastWin32Error();
40+
return err == ERROR_ACCESS_DENIED
41+
? SessionInteractivity.Locked
42+
: SessionInteractivity.Unknown;
43+
}
44+
45+
try
46+
{
47+
GetUserObjectInformation(hDesktop, UOI_NAME, IntPtr.Zero, 0, out var needed);
48+
if (needed == 0)
49+
return SessionInteractivity.Unknown;
50+
51+
var buf = Marshal.AllocHGlobal((int)needed);
52+
try
53+
{
54+
if (!GetUserObjectInformation(hDesktop, UOI_NAME, buf, needed, out _))
55+
return SessionInteractivity.Unknown;
56+
var name = Marshal.PtrToStringUni(buf) ?? string.Empty;
57+
return string.Equals(name, "Default", StringComparison.OrdinalIgnoreCase)
58+
? SessionInteractivity.Active
59+
: SessionInteractivity.Locked;
60+
}
61+
finally
62+
{
63+
Marshal.FreeHGlobal(buf);
64+
}
65+
}
66+
finally
67+
{
68+
CloseDesktop(hDesktop);
69+
}
70+
}
71+
72+
/// <summary>
73+
/// Throws <see cref="AssertInconclusiveException"/> with a clear message and
74+
/// writes a marker file if the session is not Active. The test framework
75+
/// records the outcome as Inconclusive (not Failed), and the loop runner sees
76+
/// the marker and stops scheduling further iterations.
77+
/// </summary>
78+
public static void EnsureInteractive(string operation)
79+
{
80+
var state = GetState();
81+
// Unknown means the OS gave us an unexpected error from the desktop
82+
// probe — don't fabricate a verdict. Let the test run; if WinAppDriver
83+
// really can't drive input, the WebDriverException recheck will catch
84+
// a definite Locked/Disconnected on the second look.
85+
if (state == SessionInteractivity.Active || state == SessionInteractivity.Unknown)
86+
return;
87+
88+
WriteMarker(state, operation);
89+
Assert.Inconclusive(
90+
$"Cannot perform '{operation}': workstation is {state}. " +
91+
"UI automation needs an active interactive desktop — locked screen, " +
92+
"idle/sleep lock, or RDP disconnect makes every WinAppDriver Click() " +
93+
"fail with a generic WebDriverException. Treating these as Inconclusive " +
94+
"(not Failed). Unlock the session and rerun.");
95+
}
96+
97+
/// <summary>
98+
/// If <paramref name="operation"/> threw a <see cref="OpenQA.Selenium.WebDriverException"/>,
99+
/// recheck interactivity and turn the failure into Inconclusive when the screen
100+
/// has locked since the operation started. Otherwise rethrows the original.
101+
/// </summary>
102+
public static void RecheckAfterWebDriverFailure(string operation)
103+
{
104+
var state = GetState();
105+
// Only reclassify when we have positive evidence the desktop is
106+
// unreachable. Active and Unknown both fall through and the original
107+
// WebDriverException is rethrown — masking a real failure as
108+
// Inconclusive on Unknown would lose signal in the diagnostic loop
109+
// we built this for.
110+
if (state == SessionInteractivity.Active || state == SessionInteractivity.Unknown)
111+
return; // Real test failure — caller should rethrow.
112+
113+
WriteMarker(state, operation);
114+
Assert.Inconclusive(
115+
$"'{operation}' failed and the workstation is now {state}. " +
116+
"The failure is environmental (locked desktop / disconnected session), " +
117+
"not a test bug. Marker written; remaining tests will short-circuit.");
118+
}
119+
120+
private static void WriteMarker(SessionInteractivity state, string operation)
121+
{
122+
try
123+
{
124+
var path = Environment.GetEnvironmentVariable(MarkerEnvVar);
125+
if (string.IsNullOrEmpty(path))
126+
path = Path.Combine(Path.GetTempPath(), "reactor_e2e_session_locked.flag");
127+
128+
// FileMode.CreateNew is atomic — first writer wins under parallel
129+
// contention, and a stale marker from a previous loop won't get
130+
// silently overwritten with a misleading new timestamp. The runner
131+
// is responsible for clearing the path between iterations (it
132+
// points at a fresh per-run directory each time).
133+
var bytes = System.Text.Encoding.UTF8.GetBytes(
134+
$"timestamp={DateTimeOffset.Now:O}\n" +
135+
$"state={state}\n" +
136+
$"operation={operation}\n" +
137+
$"pid={Environment.ProcessId}\n");
138+
using var fs = new FileStream(
139+
path, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
140+
fs.Write(bytes, 0, bytes.Length);
141+
}
142+
catch (IOException)
143+
{
144+
// Marker already exists — first writer won. Their state/operation
145+
// is what we want to preserve, so don't overwrite.
146+
}
147+
catch
148+
{
149+
// Best-effort — never let marker writing mask the real signal.
150+
}
151+
}
152+
153+
// ─── P/Invoke ────────────────────────────────────────────────────────────
154+
155+
private const uint DESKTOP_READOBJECTS = 0x0001;
156+
private const int UOI_NAME = 2;
157+
private const int ERROR_ACCESS_DENIED = 5;
158+
159+
[DllImport("user32.dll", SetLastError = true)]
160+
private static extern IntPtr OpenInputDesktop(uint dwFlags, bool fInherit, uint dwDesiredAccess);
161+
162+
[DllImport("user32.dll", SetLastError = true)]
163+
[return: MarshalAs(UnmanagedType.Bool)]
164+
private static extern bool CloseDesktop(IntPtr hDesktop);
165+
166+
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
167+
[return: MarshalAs(UnmanagedType.Bool)]
168+
private static extern bool GetUserObjectInformation(
169+
IntPtr hObj, int nIndex, IntPtr pvInfo, uint nLength, out uint lpnLengthNeeded);
170+
171+
private const int WTS_CURRENT_SESSION = -1;
172+
private const int WTSConnectState_InfoClass = 8;
173+
private const int WTSActive = 0;
174+
private static readonly IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;
175+
176+
[DllImport("wtsapi32.dll", SetLastError = true)]
177+
[return: MarshalAs(UnmanagedType.Bool)]
178+
private static extern bool WTSQuerySessionInformation(
179+
IntPtr hServer, int sessionId, int infoClass,
180+
out IntPtr ppBuffer, out int pBytesReturned);
181+
182+
[DllImport("wtsapi32.dll")]
183+
private static extern void WTSFreeMemory(IntPtr pMemory);
184+
185+
private static bool TryGetConnectState(out int state)
186+
{
187+
state = WTSActive;
188+
if (!WTSQuerySessionInformation(
189+
WTS_CURRENT_SERVER_HANDLE, WTS_CURRENT_SESSION,
190+
WTSConnectState_InfoClass, out var buf, out _))
191+
{
192+
return false;
193+
}
194+
try
195+
{
196+
state = Marshal.ReadInt32(buf);
197+
return true;
198+
}
199+
finally
200+
{
201+
WTSFreeMemory(buf);
202+
}
203+
}
204+
}

tests/Reactor.AppTests/Infrastructure/TestSession.cs

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ public static void AssemblyInit(TestContext context)
4141
return;
4242
}
4343

44+
// Bail out cleanly if the desktop is already locked / disconnected.
45+
// Without this, we'd spend minutes booting WinAppDriver + the host app,
46+
// only to fail every test on the first Click() with a generic error.
47+
SessionInteractivityGuard.EnsureInteractive("TestSession.AssemblyInit");
48+
4449
// Kill any orphaned processes from a previous failed run
4550
KillOrphanedProcesses();
4651

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

59-
// Poll for the app window instead of a fixed sleep
60-
WaitForHostWindow();
64+
try
65+
{
66+
// Poll for the app window instead of a fixed sleep. WaitForHostWindow
67+
// throws TimeoutException after swallowing per-poll WebDriverExceptions —
68+
// a mid-init screen lock surfaces here, not as a WebDriverException.
69+
WaitForHostWindow();
6170

62-
// Step 2: Create a Desktop session and find the app window
63-
var desktopOptions = new AppiumOptions();
64-
desktopOptions.AddAdditionalCapability("app", "Root");
65-
desktopOptions.AddAdditionalCapability("deviceName", "WindowsPC");
71+
// Step 2: Create a Desktop session and find the app window
72+
var desktopOptions = new AppiumOptions();
73+
desktopOptions.AddAdditionalCapability("app", "Root");
74+
desktopOptions.AddAdditionalCapability("deviceName", "WindowsPC");
6675

67-
using var desktopSession = new WindowsDriver<WindowsElement>(
68-
new Uri(WinAppDriverUrl), desktopOptions);
76+
using var desktopSession = new WindowsDriver<WindowsElement>(
77+
new Uri(WinAppDriverUrl), desktopOptions);
6978

70-
// Find the Host app window by title
71-
var appWindow = desktopSession.FindElementByName("Reactor Test Host");
72-
var appWindowHandle = appWindow.GetAttribute("NativeWindowHandle");
73-
var hwnd = int.Parse(appWindowHandle).ToString("x"); // hex for WinAppDriver
79+
// Find the Host app window by title
80+
var appWindow = desktopSession.FindElementByName("Reactor Test Host");
81+
var appWindowHandle = appWindow.GetAttribute("NativeWindowHandle");
82+
var hwnd = int.Parse(appWindowHandle).ToString("x"); // hex for WinAppDriver
7483

75-
// Step 3: Create a session attached to the app window
76-
var appOptions = new AppiumOptions();
77-
appOptions.AddAdditionalCapability("appTopLevelWindow", $"0x{hwnd}");
78-
appOptions.AddAdditionalCapability("deviceName", "WindowsPC");
84+
// Step 3: Create a session attached to the app window
85+
var appOptions = new AppiumOptions();
86+
appOptions.AddAdditionalCapability("appTopLevelWindow", $"0x{hwnd}");
87+
appOptions.AddAdditionalCapability("deviceName", "WindowsPC");
7988

80-
_session = new WindowsDriver<WindowsElement>(new Uri(WinAppDriverUrl), appOptions);
81-
_session.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(2);
89+
_session = new WindowsDriver<WindowsElement>(new Uri(WinAppDriverUrl), appOptions);
90+
_session.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(2);
8291

83-
Console.WriteLine("WindowsDriver session attached to Host app.");
92+
Console.WriteLine("WindowsDriver session attached to Host app.");
93+
}
94+
catch (Exception ex) when (ex is OpenQA.Selenium.WebDriverException || ex is TimeoutException)
95+
{
96+
// Catches both: WebDriverException from the session steps, and
97+
// TimeoutException from WaitForHostWindow. Either could mask a
98+
// workstation lock that happened after the AssemblyInit preflight.
99+
SessionInteractivityGuard.RecheckAfterWebDriverFailure("TestSession session bootstrap");
100+
throw;
101+
}
84102
}
85103

86104
/// <summary>

0 commit comments

Comments
 (0)