diff --git a/Daqifi.Desktop.Test/Daqifi.Desktop.Test.csproj b/Daqifi.Desktop.Test/Daqifi.Desktop.Test.csproj
index 59b4720..199718d 100644
--- a/Daqifi.Desktop.Test/Daqifi.Desktop.Test.csproj
+++ b/Daqifi.Desktop.Test/Daqifi.Desktop.Test.csproj
@@ -11,6 +11,8 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
+
diff --git a/Daqifi.Desktop.Test/UITests/ConnectStreamDisconnectTests.cs b/Daqifi.Desktop.Test/UITests/ConnectStreamDisconnectTests.cs
new file mode 100644
index 0000000..2b098f8
--- /dev/null
+++ b/Daqifi.Desktop.Test/UITests/ConnectStreamDisconnectTests.cs
@@ -0,0 +1,472 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using FlaUI.Core;
+using FlaUI.Core.AutomationElements;
+using FlaUI.Core.Conditions;
+using FlaUI.Core.Definitions;
+using FlaUI.UIA3;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Daqifi.Desktop.Test.UITests;
+
+///
+/// Phase 2 of the FlaUI UI-automation scaffold (issue #531).
+///
+/// Drives the full connect -> enable-channel -> stream -> disconnect happy path
+/// against a real DAQiFi device.
+///
+/// Skip behavior is opt-in rather than opt-out. The test self-skips unless the
+/// environment variable DAQIFI_BENCH_DEVICE_AVAILABLE is set to a truthy
+/// value ("1", "true", or "yes", case-insensitive). On a normal CI run with no
+/// bench device wired up, the test reports Inconclusive with a pointer
+/// to issue #531; on the bench machine, set the env var and the test runs.
+///
+/// Before enabling on the bench:
+/// 1. A DAQiFi Nyquist must be attached via USB (or reachable via Wi-Fi).
+/// 2. The required XAML controls must be annotated with the AutomationIds
+/// referenced below. Each Id has a comment in the XAML pointing back to
+/// this test + issue #531 for traceability.
+/// 3. The desktop app must be built (Phase 1 verifies the launch path).
+///
+/// Naming convention used for AutomationIds: "Daqifi.<Pane>.<Control>",
+/// e.g. "Daqifi.Connection.AddDeviceButton". Keeping a stable, dotted namespace
+/// makes future selectors greppable in the XAML.
+///
+[TestClass]
+public class ConnectStreamDisconnectTests
+{
+ private const string DESKTOP_EXE_NAME = "DAQiFi.exe";
+ private const string DESKTOP_PROJECT_NAME = "Daqifi.Desktop";
+
+ // Env-var gate: the test only runs when this is set to a truthy value on the
+ // host. This replaces the previous unconditional [Ignore] attribute so the
+ // bench machine can opt in without a code change (PR #531 / Qodo finding #3).
+ private const string BENCH_AVAILABLE_ENV_VAR = "DAQIFI_BENCH_DEVICE_AVAILABLE";
+
+ // AutomationIds that Phase 2 expects to exist in MainWindow / dialogs.
+ // None of these are wired up yet; add them in the corresponding XAML with
+ // a comment referencing this test + #531 when enabling the test.
+ private const string ADD_DEVICE_BUTTON_ID = "Daqifi.Connection.AddDeviceButton";
+ private const string CONNECT_BUTTON_ID = "Daqifi.Connection.ConnectButton";
+ private const string DEVICE_LIST_ID = "Daqifi.Devices.ConnectedList";
+ private const string FIRST_CHANNEL_TOGGLE_ID = "Daqifi.Channels.FirstChannelEnable";
+ private const string START_STREAMING_ID = "Daqifi.Streaming.StartButton";
+ private const string STOP_STREAMING_ID = "Daqifi.Streaming.StopButton";
+ private const string DISCONNECT_BUTTON_ID = "Daqifi.Connection.DisconnectButton";
+ private const string LIVE_GRAPH_ID = "Daqifi.Graph.Live";
+
+ // ConnectionDialog (a separate top-level MetroWindow) is identified by its
+ // window title; see Daqifi.Desktop/View/ConnectionDialog.xaml.
+ private const string CONNECTION_DIALOG_TITLE = "CONNECT DEVICE";
+
+ private static readonly TimeSpan MAIN_WINDOW_TIMEOUT = TimeSpan.FromSeconds(60);
+ private static readonly TimeSpan DEVICE_APPEAR_TIMEOUT = TimeSpan.FromSeconds(15);
+ private static readonly TimeSpan CONNECTION_DIALOG_TIMEOUT = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan TOGGLE_PROPAGATION_TIMEOUT = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan STREAMING_DWELL_TIME = TimeSpan.FromSeconds(3);
+ private static readonly TimeSpan SHUTDOWN_GRACE = TimeSpan.FromSeconds(5);
+
+ [TestMethod]
+ [TestCategory("UI-Bench")]
+ public void ConnectStreamDisconnectHappyPath()
+ {
+ if (!IsBenchDeviceAvailable())
+ {
+ Assert.Inconclusive(
+ "Skipped: no bench device available. Set the environment variable " +
+ $"{BENCH_AVAILABLE_ENV_VAR}=1 on the bench machine (with a DAQiFi Nyquist " +
+ "attached and XAML AutomationIds wired up) to run this happy-path test. " +
+ "See issue #531.");
+ return; // unreachable
+ }
+
+ var exePath = MainWindowSmokeTests.TryLocateDesktopExe();
+ if (exePath is null)
+ {
+ Assert.Inconclusive(
+ $"Skipped: {DESKTOP_EXE_NAME} was not found. Build the {DESKTOP_PROJECT_NAME} " +
+ "project first.");
+ return; // unreachable
+ }
+
+ Application? app = null;
+ try
+ {
+ app = UIAppLifecycle.LaunchOrInconclusive(exePath);
+
+ using var automation = new UIA3Automation();
+ var mainWindow = app.GetMainWindow(automation, MAIN_WINDOW_TIMEOUT);
+ Assert.IsNotNull(mainWindow, "Main window did not appear.");
+ var cf = automation.ConditionFactory;
+
+ // ----- Connect -----
+ // The Add Device button lives on the main window; clicking it opens the
+ // separate ConnectionDialog (a MetroWindow) where the Connect button
+ // actually lives.
+ var addDevice = FindByAutomationId(mainWindow, cf, ADD_DEVICE_BUTTON_ID,
+ "Add-device entry point (USB/Serial picker).");
+ addDevice.AsButton().Invoke();
+
+ var connectionDialog = WaitForTopLevelWindow(app, automation,
+ CONNECTION_DIALOG_TITLE, CONNECTION_DIALOG_TIMEOUT,
+ "Connection dialog did not appear after invoking Add Device.");
+
+ var connect = FindByAutomationId(connectionDialog, cf, CONNECT_BUTTON_ID,
+ "Confirm button on the connection dialog.");
+ connect.AsButton().Invoke();
+
+ // Fail-fast if the device-list AutomationId itself is missing or
+ // regressed: otherwise FindListItems would return ListFound=false,
+ // ItemCount=0, the WaitForOrInconclusive predicate (ItemCount > 0)
+ // would never become true, and we'd time out as Inconclusive -
+ // masking a real selector regression as "bench device not
+ // available" (Qodo review pass 7 #1).
+ _ = FindByAutomationId(mainWindow, cf, DEVICE_LIST_ID,
+ "Connected-devices list container.");
+
+ // Wait for the connected-devices list to show at least one row.
+ // Re-find the list inside the predicate each poll: UI Automation
+ // elements can become stale across major tree transitions (e.g.
+ // when the ConnectionDialog closes), and a cached element that's
+ // gone stale will keep throwing inside WaitFor until timeout.
+ //
+ // If the device never appears within the timeout, treat it as
+ // "bench device not actually discoverable" and skip
+ // (Assert.Inconclusive) rather than fail - the env-var gate told
+ // us a device was *expected*, but the connect path can still no-op
+ // when the hardware is powered off or the cable is unplugged.
+ // Phase 2 must skip-on-unavailable per the #531 compliance bar
+ // (Qodo review #1, pass 4).
+ WaitForOrInconclusive(
+ () => FindListItems(mainWindow, cf, DEVICE_LIST_ID).ItemCount > 0,
+ DEVICE_APPEAR_TIMEOUT,
+ "Device did not appear in the connected list within "
+ + $"{DEVICE_APPEAR_TIMEOUT.TotalSeconds:F0}s. The "
+ + $"{BENCH_AVAILABLE_ENV_VAR} env var is set but no device was "
+ + "discovered - check the USB connection / power state.");
+
+ // ----- Enable first channel -----
+ // Set the toggle deterministically to the 'On' state rather than
+ // unconditionally inverting it. If the channel was already enabled
+ // (e.g., from persisted UI state or a prior run on the bench),
+ // a blind Toggle() would disable it and the rest of the flow
+ // (Start streaming, graph proof-of-life, Disconnect) would fail
+ // or observe no data.
+ var firstChannel = FindByAutomationId(mainWindow, cf, FIRST_CHANNEL_TOGGLE_ID,
+ "Enable-toggle on the first analog channel.");
+ var firstChannelToggle = firstChannel.AsToggleButton();
+ if (firstChannelToggle.ToggleState != ToggleState.On)
+ {
+ firstChannelToggle.Toggle();
+
+ // Wait for the toggle state change to propagate through the
+ // WPF data-binding before asserting downstream stream behavior.
+ // Re-find the element each poll so we don't rely on a possibly
+ // stale AutomationElement reference after the UI updates.
+ WaitFor(
+ () => FindByAutomationId(mainWindow, cf, FIRST_CHANNEL_TOGGLE_ID,
+ "First-channel toggle (post-toggle).")
+ .AsToggleButton()
+ .ToggleState == ToggleState.On,
+ TOGGLE_PROPAGATION_TIMEOUT,
+ "First channel did not reach the 'On' state after toggling.");
+ }
+
+ // ----- Capture graph baseline, start streaming, check for update -----
+ // Capture graph geometry BEFORE invoking Start so we have a
+ // baseline to diff against. UI Automation can't see OxyPlot /
+ // LiveCharts plot data directly, so the best UIA-only proxy for
+ // "data is arriving" is "the live graph's bounding rectangle
+ // changed after streaming started". Once the XAML grows an
+ // automation-visible point-count probe (tracked under #531) this
+ // can become a hard assertion.
+ //
+ // The baseline MUST be captured before invoking Start: otherwise
+ // the WPF data-binding could have already pushed the first sample
+ // by the time we read the rectangle, and the post-stream diff
+ // would compare against an already-updated baseline (false-fail).
+ var liveGraph = FindByAutomationId(mainWindow, cf, LIVE_GRAPH_ID,
+ "Live graph control. Should visibly update after streaming starts.");
+ Assert.IsTrue(liveGraph.BoundingRectangle.Width > 0,
+ "Pre-stream graph rectangle was empty; the control wasn't laid out before Start.");
+ var preStreamRect = liveGraph.BoundingRectangle;
+
+ var start = FindByAutomationId(mainWindow, cf, START_STREAMING_ID,
+ "Start-streaming command button.");
+ start.AsButton().Invoke();
+
+ // Poll-and-detect rather than fixed-sleep: WaitFor returns as soon
+ // as we observe a visible change to the graph (and skips the
+ // remaining dwell), but if nothing has changed by the deadline we
+ // still spent the same wall-clock budget as the old Thread.Sleep
+ // and get a precise failure message ("did not visibly update").
+ WaitFor(
+ () =>
+ {
+ var graph = FindByAutomationId(mainWindow, cf, LIVE_GRAPH_ID,
+ "Live graph control (streaming-update poll).");
+ var rect = graph.BoundingRectangle;
+ return !graph.IsOffscreen
+ && graph.IsEnabled
+ && rect.Width > 0
+ && rect.Height > 0
+ && !rect.Equals(preStreamRect);
+ },
+ STREAMING_DWELL_TIME,
+ "Live graph did not visibly update during the streaming dwell; "
+ + "streaming may not have started or data is not reaching the plot.");
+
+ // ----- Stop streaming -----
+ var stop = FindByAutomationId(mainWindow, cf, STOP_STREAMING_ID,
+ "Stop-streaming command button.");
+ stop.AsButton().Invoke();
+
+ // ----- Disconnect -----
+ var disconnect = FindByAutomationId(mainWindow, cf, DISCONNECT_BUTTON_ID,
+ "Disconnect command button.");
+ disconnect.AsButton().Invoke();
+
+ // Require BOTH that the list element still exists AND that its
+ // data-item count is zero. Without the ListFound guard, an
+ // AutomationId regression (or a transient UIA-tree drop) would
+ // make `ItemCount == 0` pass for the wrong reason: "the list is
+ // gone" looks identical to "the list is empty" (Qodo review #8).
+ WaitFor(
+ () =>
+ {
+ var snap = FindListItems(mainWindow, cf, DEVICE_LIST_ID);
+ return snap.ListFound && snap.ItemCount == 0;
+ },
+ DEVICE_APPEAR_TIMEOUT,
+ "Device was not removed from the connected list after disconnect.");
+ }
+ finally
+ {
+ UIAppLifecycle.CloseAppGracefully(app, SHUTDOWN_GRACE);
+ }
+ }
+
+ private static bool IsBenchDeviceAvailable()
+ {
+ var raw = Environment.GetEnvironmentVariable(BENCH_AVAILABLE_ENV_VAR);
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return false;
+ }
+
+ var trimmed = raw.Trim();
+ return trimmed.Equals("1", StringComparison.OrdinalIgnoreCase)
+ || trimmed.Equals("true", StringComparison.OrdinalIgnoreCase)
+ || trimmed.Equals("yes", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static AutomationElement FindByAutomationId(
+ AutomationElement scope, ConditionFactory cf, string automationId, string description)
+ {
+ var element = scope.FindFirstDescendant(cf.ByAutomationId(automationId));
+ Assert.IsNotNull(element,
+ $"Could not find AutomationId '{automationId}' ({description}). " +
+ "Add AutomationProperties.AutomationId to the XAML and reference issue #531.");
+ return element!;
+ }
+
+ ///
+ /// Result of looking up a list container by AutomationId and counting its
+ /// data rows. distinguishes "list element missing"
+ /// from "list found and empty" - a disconnect predicate that just checked
+ /// for "0 children" would otherwise produce a false-positive pass when
+ /// the AutomationId regressed (Qodo review #8).
+ ///
+ private readonly record struct ListItemSnapshot(bool ListFound, int ItemCount);
+
+ ///
+ /// Re-finds the list element by AutomationId and counts its data rows.
+ /// Always re-locates the parent on each call so a stale AutomationElement
+ /// from a prior UI-tree refresh can't poison a polling loop.
+ ///
+ /// Counts use UI Automation patterns first (Grid.RowCount or
+ /// ListBox.Items.Length) because WPF UI virtualization can leave rows
+ /// unrealized (offscreen list items are not present as descendants in the
+ /// UIA tree until they scroll into view) - so counting realized
+ /// descendants alone would report 0 even when the list has items. The
+ /// realized-descendant fallback (ListItem / DataItem) covers the case
+ /// where the control isn't a ListBox / Grid but still exposes its items
+ /// directly.
+ ///
+ private static ListItemSnapshot FindListItems(
+ AutomationElement scope, ConditionFactory cf, string automationId)
+ {
+ var element = scope.FindFirstDescendant(cf.ByAutomationId(automationId));
+ if (element is null)
+ {
+ return new ListItemSnapshot(ListFound: false, ItemCount: 0);
+ }
+
+ // Prefer GridPattern.RowCount when available - it reports the logical
+ // row count regardless of virtualization. WPF DataGrids commonly count
+ // their header band as a row in GridPattern; if a Header descendant
+ // exists, subtract one so an empty grid can reach ItemCount=0
+ // (otherwise the disconnect predicate would never see 0 and would
+ // time out).
+ var gridPattern = element.Patterns.Grid.PatternOrDefault;
+ if (gridPattern is not null)
+ {
+ // GridPattern.RowCount is AutomationProperty; .Value reads
+ // the actual count via UIA.
+ var rowCount = gridPattern.RowCount.Value;
+ var header = element.FindFirstDescendant(cf.ByControlType(ControlType.Header));
+ if (header is not null && rowCount > 0)
+ {
+ rowCount--;
+ }
+ return new ListItemSnapshot(ListFound: true, ItemCount: Math.Max(0, rowCount));
+ }
+
+ // ListBox.Items.Length is similarly virtualization-safe; AsListBox
+ // throws if the element isn't a ListBox, so guard with try/catch
+ // rather than a fragile control-type sniff.
+ try
+ {
+ var listBox = element.AsListBox();
+ return new ListItemSnapshot(ListFound: true, ItemCount: listBox.Items.Length);
+ }
+ catch
+ {
+ // Fall through to the realized-descendant fallback.
+ }
+
+ // Last-resort fallback: count realized ListItem / DataItem descendants.
+ // This loses accuracy under virtualization but works for non-virtualized
+ // ItemsControls and DataGrids.
+ var listItems = element.FindAllDescendants(cf.ByControlType(ControlType.ListItem));
+ if (listItems.Length > 0)
+ {
+ return new ListItemSnapshot(ListFound: true, ItemCount: listItems.Length);
+ }
+
+ var dataItems = element.FindAllDescendants(cf.ByControlType(ControlType.DataItem));
+ return new ListItemSnapshot(ListFound: true, ItemCount: dataItems.Length);
+ }
+
+ ///
+ /// Polls the app's top-level windows for one whose title contains
+ /// (case-insensitive). The ConnectionDialog
+ /// is a separate MetroWindow, so descendants of the main window can't
+ /// see it.
+ ///
+ private static Window WaitForTopLevelWindow(
+ Application app, UIA3Automation automation, string titleFragment,
+ TimeSpan timeout, string failureMessage)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ Exception? lastException = null;
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ var match = app.GetAllTopLevelWindows(automation)
+ .FirstOrDefault(w => (w.Title ?? string.Empty)
+ .Contains(titleFragment, StringComparison.OrdinalIgnoreCase));
+ if (match is not null)
+ {
+ return match;
+ }
+ }
+ catch (Exception ex)
+ {
+ lastException = ex;
+ }
+
+ Thread.Sleep(200);
+ }
+
+ Assert.Fail(BuildTimeoutMessage(failureMessage, timeout,
+ "enumerating top-level windows", lastException));
+ throw new InvalidOperationException("unreachable; Assert.Fail throws.");
+ }
+
+ private static void WaitFor(Func condition, TimeSpan timeout, string failureMessage)
+ {
+ if (TryPoll(condition, timeout, out var lastException))
+ {
+ return;
+ }
+
+ Assert.Fail(BuildTimeoutMessage(failureMessage, timeout,
+ "polling", lastException));
+ }
+
+ ///
+ /// Same poll-then-give-up shape as , but reports
+ /// timeout via rather than
+ /// . Used when "condition didn't become
+ /// true" means "the bench device isn't actually available" (the env-var
+ /// gate says we should run, but the hardware turned out to be absent /
+ /// powered off / unplugged) - which the #531 compliance bar requires to
+ /// be a skip, not a failure.
+ ///
+ private static void WaitForOrInconclusive(
+ Func condition, TimeSpan timeout, string failureMessage)
+ {
+ if (TryPoll(condition, timeout, out var lastException))
+ {
+ return;
+ }
+
+ Assert.Inconclusive(BuildTimeoutMessage(failureMessage, timeout,
+ "polling", lastException));
+ }
+
+ ///
+ /// Shared poll loop for and
+ /// . Returns true when
+ /// evaluated to true before the deadline;
+ /// returns false on timeout, with the last observed exception (if
+ /// any) emitted via so the caller can
+ /// surface it.
+ ///
+ private static bool TryPoll(
+ Func condition, TimeSpan timeout, out Exception? lastException)
+ {
+ lastException = null;
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ if (condition())
+ {
+ return true;
+ }
+ }
+ catch (Exception ex)
+ {
+ // Swallow but remember transient UIA errors while controls are still
+ // spinning up; if we eventually time out, surface the last error so
+ // the failure message points at the real root cause.
+ lastException = ex;
+ }
+ Thread.Sleep(200);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Builds a uniform "timed out" failure message that appends the last
+ /// observed exception (if any). Kept under the 120-column limit by
+ /// constructing the exception suffix on its own line.
+ ///
+ private static string BuildTimeoutMessage(
+ string failureMessage, TimeSpan timeout, string context, Exception? lastException)
+ {
+ var suffix = lastException is null
+ ? string.Empty
+ : $" Last exception during {context}: "
+ + $"{lastException.GetType().Name}: {lastException.Message}";
+ return $"{failureMessage} (waited {timeout.TotalSeconds:F0}s).{suffix}";
+ }
+}
diff --git a/Daqifi.Desktop.Test/UITests/MainWindowSmokeTests.cs b/Daqifi.Desktop.Test/UITests/MainWindowSmokeTests.cs
new file mode 100644
index 0000000..6b11e41
--- /dev/null
+++ b/Daqifi.Desktop.Test/UITests/MainWindowSmokeTests.cs
@@ -0,0 +1,116 @@
+using System;
+using System.IO;
+using System.Reflection;
+using FlaUI.Core;
+using FlaUI.UIA3;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Daqifi.Desktop.Test.UITests;
+
+///
+/// Phase 1 of the FlaUI UI-automation scaffold (issue #531).
+///
+/// Launches the DAQiFi Desktop executable, asserts the main window appears and
+/// has a sensible title, then closes the app cleanly. Requires the WPF app to
+/// already be built; the test is marked Inconclusive (skipped) if the exe is
+/// not on disk so a clean CI run without a built desktop is informative rather
+/// than red.
+///
+/// This is intentionally minimal: it proves FlaUI can drive the app under
+/// MSTest. Phase 2 builds the connect/stream/disconnect happy path on top of
+/// the helpers established here.
+///
+[TestClass]
+public class MainWindowSmokeTests
+{
+ // Assembly name is "DAQiFi" per Daqifi.Desktop.csproj ; the
+ // produced exe is therefore DAQiFi.exe under the desktop project's bin dir.
+ private const string DESKTOP_EXE_NAME = "DAQiFi.exe";
+ private const string DESKTOP_PROJECT_NAME = "Daqifi.Desktop";
+ private const string EXPECTED_TITLE_FRAGMENT = "DAQiFi";
+
+ // Allow up to 60s for the main window to appear; WPF cold-start can be slow
+ // on first launch (JIT, MEF composition, MahApps theme load).
+ private static readonly TimeSpan MAIN_WINDOW_TIMEOUT = TimeSpan.FromSeconds(60);
+
+ // Grace period for graceful shutdown after Close(); matches Phase 2 teardown.
+ private static readonly TimeSpan SHUTDOWN_GRACE = TimeSpan.FromSeconds(5);
+
+ [TestMethod]
+ [TestCategory("UI")]
+ public void MainWindow_Launches_And_HasExpectedTitle()
+ {
+ var exePath = TryLocateDesktopExe();
+ if (exePath is null)
+ {
+ Assert.Inconclusive(
+ $"Skipped: {DESKTOP_EXE_NAME} was not found. Build the {DESKTOP_PROJECT_NAME} " +
+ "project (Debug or Release, net10.0-windows) before running this UI test. " +
+ "See issue #531 for the full FlaUI scaffold rollout plan.");
+ return; // unreachable; Assert.Inconclusive throws.
+ }
+
+ Application? app = null;
+ try
+ {
+ app = UIAppLifecycle.LaunchOrInconclusive(exePath);
+
+ using var automation = new UIA3Automation();
+ var mainWindow = app.GetMainWindow(automation, MAIN_WINDOW_TIMEOUT);
+
+ Assert.IsNotNull(mainWindow, "Main window was not found within the timeout.");
+
+ // The MainWindow code-behind sets Title = $"DAQiFi v{Major}.{Minor}.{Build}".
+ // Case-insensitive substring match keeps the assertion resilient to
+ // version-string changes.
+ var title = mainWindow.Title ?? string.Empty;
+ Assert.IsTrue(
+ title.Contains(EXPECTED_TITLE_FRAGMENT, StringComparison.OrdinalIgnoreCase),
+ $"Expected window title to contain '{EXPECTED_TITLE_FRAGMENT}', but was '{title}'.");
+ }
+ finally
+ {
+ UIAppLifecycle.CloseAppGracefully(app, SHUTDOWN_GRACE);
+ }
+ }
+
+ ///
+ /// Tries common build-output locations for DAQiFi.exe. Returns null if none
+ /// of them exist; the caller turns that into Assert.Inconclusive.
+ ///
+ internal static string? TryLocateDesktopExe()
+ {
+ // The test binary lands under
+ // /Daqifi.Desktop.Test/bin///Daqifi.Desktop.Test.dll
+ // so the repo root is four levels up.
+ var testAssemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
+ ?? Directory.GetCurrentDirectory();
+ var repoRoot = Path.GetFullPath(Path.Combine(testAssemblyDir, "..", "..", "..", ".."));
+
+ // Match the same configuration we were built with, then fall back to
+ // any sibling config so a Release test run can still drive a Debug app
+ // build (or vice versa).
+#if DEBUG
+ var preferredConfigs = new[] { "Debug", "Release" };
+#else
+ var preferredConfigs = new[] { "Release", "Debug" };
+#endif
+
+ var tfms = new[] { "net10.0-windows", "net9.0-windows" };
+
+ foreach (var config in preferredConfigs)
+ {
+ foreach (var tfm in tfms)
+ {
+ var candidate = Path.Combine(
+ repoRoot, DESKTOP_PROJECT_NAME, "bin", config, tfm, DESKTOP_EXE_NAME);
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/Daqifi.Desktop.Test/UITests/UIAppLifecycle.cs b/Daqifi.Desktop.Test/UITests/UIAppLifecycle.cs
new file mode 100644
index 0000000..8991d26
--- /dev/null
+++ b/Daqifi.Desktop.Test/UITests/UIAppLifecycle.cs
@@ -0,0 +1,139 @@
+using System;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using FlaUI.Core;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Daqifi.Desktop.Test.UITests;
+
+///
+/// Shared helpers for the FlaUI UI-test scaffold (issue #531).
+///
+/// Centralises two cross-cutting concerns so Phase 1
+/// () and Phase 2
+/// () stay in sync:
+///
+/// - : wraps
+/// Application.Launch and surfaces the UAC-elevation case
+/// (Win32 error 740) as Assert.Inconclusive instead of a
+/// hard failure, since FlaUI can't drive the UAC consent dialog.
+/// - : best-effort teardown that
+/// waits on app.HasExited (not the main-window handle)
+/// so the app's shutdown path — device disconnect, settings
+/// flush — has a chance to finish before Kill().
+///
+internal static class UIAppLifecycle
+{
+ ///
+ /// Launches the given executable and returns the
+ /// handle, or marks the test inconclusive (rather than failing) when the
+ /// launch would require UAC elevation that the test runner can't drive.
+ ///
+ ///
+ /// Daqifi.Desktop/app.manifest declares
+ /// requestedExecutionLevel="requireAdministrator", so
+ /// Process.Start fails with ERROR_ELEVATION_REQUIRED (740)
+ /// unless the test runner is already elevated. The two working approaches
+ /// are:
+ /// 1. Run dotnet test from an elevated terminal / CI agent, OR
+ /// 2. Build a non-elevated test target of the app (manifest =
+ /// asInvoker) - see #531.
+ ///
+ public static Application LaunchOrInconclusive(string exePath)
+ {
+ try
+ {
+ // Set WorkingDirectory to the exe's folder so the app's relative
+ // path lookups (config files, resource probing, settings stores)
+ // resolve from a known location instead of inheriting the test
+ // runner's cwd (which can be anything from MSTest's bin/ down to
+ // wherever the IDE was launched from).
+ var startInfo = new ProcessStartInfo(exePath)
+ {
+ WorkingDirectory = Path.GetDirectoryName(exePath)
+ ?? Environment.CurrentDirectory,
+ };
+ return Application.Launch(startInfo);
+ }
+ catch (Win32Exception ex) when (ex.NativeErrorCode == 740 /* ERROR_ELEVATION_REQUIRED */)
+ {
+ Assert.Inconclusive(
+ "Skipped: DAQiFi.exe requires administrator elevation (app.manifest sets " +
+ "requestedExecutionLevel=requireAdministrator). Run the test from an elevated " +
+ "terminal, or switch the manifest to asInvoker for the UI-test build. See #531.");
+ // Assert.Inconclusive throws; this return keeps the compiler happy.
+ throw;
+ }
+ }
+
+ ///
+ /// Requests graceful shutdown via Close(), polls
+ /// app.HasExited for up to , then forces
+ /// Kill() if necessary, and always Dispose()s.
+ ///
+ /// Polling HasExited rather than calling
+ /// WaitWhileMainHandleIsMissing ensures we wait for the
+ /// process to exit, not just for the main-window handle to
+ /// vanish; the latter can return while the app's shutdown handlers
+ /// (device disconnect, settings flush) are still running.
+ ///
+ public static void CloseAppGracefully(Application? app, TimeSpan grace)
+ {
+ if (app is null)
+ {
+ return;
+ }
+
+ try
+ {
+ app.Close();
+
+ var deadline = DateTime.UtcNow + grace;
+ while (DateTime.UtcNow < deadline && !app.HasExited)
+ {
+ Thread.Sleep(100);
+ }
+
+ if (!app.HasExited)
+ {
+ app.Kill();
+ // Kill() is asynchronous - the OS posts the termination but the
+ // process can still hold file/socket handles for a moment after.
+ // Block here so callers see a fully-exited process when
+ // Dispose() runs, preventing stray processes between consecutive
+ // test runs. FlaUI's Application doesn't expose the underlying
+ // Process directly, but we can fetch it by ProcessId.
+ WaitForExitByProcessId(app.ProcessId, grace);
+ }
+ }
+ catch
+ {
+ // Best-effort teardown - never let cleanup mask the real failure.
+ }
+ finally
+ {
+ app.Dispose();
+ }
+ }
+
+ ///
+ /// Blocks until the OS process with the given PID has exited or the grace
+ /// window expires. Swallows the (expected)
+ /// from when the process is
+ /// already gone by the time we look it up.
+ ///
+ private static void WaitForExitByProcessId(int processId, TimeSpan grace)
+ {
+ try
+ {
+ using var process = Process.GetProcessById(processId);
+ process.WaitForExit((int)grace.TotalMilliseconds);
+ }
+ catch (ArgumentException)
+ {
+ // Process already exited - no further wait needed.
+ }
+ }
+}