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. + } + } +}