using System.Diagnostics; using System.IO; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using CursorLang.Services; namespace CursorLang.Tests; /// /// The application as a whole: the start, the single instance and the exit. /// /// /// The application cannot be built inside the tests — it raises windows and /// takes the place of the single instance for the whole session. It is /// therefore started the way the user starts it: as a separate process. /// /// If the application is already running in this session, the checks skip /// themselves: meddling with someone else's running instance is not their business. /// /// They skip themselves where there is no desktop to show a window on either — on a /// build agent living as a Windows service, for one. /// public sealed class EndToEndTests { private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15); /// How long "the application went on working" is worth watching for. private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3); [Fact] public void The_application_starts_and_shows_the_settings_window() { using Launch launch = Launch.Start(); Assert.NotEqual(IntPtr.Zero, launch.WaitForWindow()); Assert.False(launch.Process.HasExited); } /// /// Started by Windows itself, the application goes straight to the tray: the /// user asked for it to be there, not for a window to greet them. /// [Fact] public void A_launch_by_Windows_shows_no_window() { using Launch launch = Launch.Start(StartupLaunch.Argument); // Nothing is expected to appear, so the wait is for the whole time Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended by itself"); launch.Process.Refresh(); Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle); } // A second run raises no second window but shows the window of the running one [Fact] public void The_second_run_ends_by_itself() { using Launch launch = Launch.Start(); launch.WaitForWindow(); using Process second = Launch.StartProcess(); Assert.True(second.WaitForExit(ExitTimeout), "the second run did not end by itself"); Assert.Equal(0, second.ExitCode); // And the first one keeps running Assert.False(launch.Process.HasExited); } // The way out of the application is the tray menu alone: the close button of // the window merely puts the window away [Fact] public void Closing_the_window_leaves_the_application_in_the_tray() { using Launch launch = Launch.Start(); launch.WaitForWindow(); Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close"); Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended together with its window"); launch.Process.Refresh(); Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle); } /// A started application that shuts down together with the check. private sealed class Launch : IDisposable { private const int UOI_NAME = 2; private const string InteractiveWindowStation = "WinSta0"; [DllImport("user32.dll")] private static extern IntPtr GetProcessWindowStation(); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern bool GetUserObjectInformation(IntPtr hObj, int nIndex, StringBuilder pvInfo, int nLength, out int lpnLengthNeeded); private Launch(Process process) => Process = process; internal Process Process { get; } /// Starts the application first — making sure the place is free. internal static Launch Start(params string[] arguments) { if (!HasInteractiveDesktop()) { Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window"); } if (Process.GetProcessesByName("CursorLang").Length > 0) { Assert.Skip("The application is already running — this check keeps out of someone else's run"); } return new Launch(StartProcess(arguments)); } /// Starts the application the way the user — or Windows — does. internal static Process StartProcess(params string[] arguments) { string path = ExecutablePath(); if (!File.Exists(path)) { Assert.Skip($"The application is not built: {path}"); } var start = new ProcessStartInfo(path) { UseShellExecute = true }; foreach (string argument in arguments) { start.ArgumentList.Add(argument); } return Process.Start(start)!; } /// /// Waits for the settings window: by the time it appears the application /// has raised its whole cast. /// internal IntPtr WaitForWindow() { DateTime deadline = DateTime.UtcNow + StartTimeout; while (DateTime.UtcNow < deadline) { Process.Refresh(); if (Process.HasExited) { Assert.Fail($"The application exited while starting with code {Process.ExitCode}"); } if (Process.MainWindowHandle != IntPtr.Zero) { return Process.MainWindowHandle; } Thread.Sleep(100); } Assert.Fail("The settings window never appeared"); return IntPtr.Zero; } /// /// Whether there is a desktop here to show a window on. A service gets a /// window station of its own — "Service-0x0-3e7$" and the like: a window can /// be created there, yet nothing shows it. Only "WinSta0" is the interactive one. /// private static bool HasInteractiveDesktop() { IntPtr station = GetProcessWindowStation(); if (station == IntPtr.Zero) { return false; } var name = new StringBuilder(256); return GetUserObjectInformation(station, UOI_NAME, name, name.Capacity * sizeof(char), out _) && name.ToString().Equals(InteractiveWindowStation, StringComparison.OrdinalIgnoreCase); } private static string ExecutablePath() { string configured = Assembly.GetExecutingAssembly() .GetCustomAttributes() .Single(attribute => attribute.Key == "CursorLangExecutable") .Value!; return Path.GetFullPath(configured); } public void Dispose() { try { if (!Process.HasExited) { // Asking the window to close would only put it away into the // tray, and the exit lives in a menu no test can reach. The // settings are saved as they change, so nothing is lost here Process.Kill(entireProcessTree: true); Process.WaitForExit(ExitTimeout); } } catch (InvalidOperationException) { // The process has already ended on its own } finally { Process.Dispose(); } } } }