using System.Diagnostics; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using CursorLang.Core.Services; namespace CursorLang.Agent.Tests; /// /// The application as a whole: the agent starting, the settings window it opens, the /// single instance and the exit. /// /// /// None of this can be built inside the tests — the agent installs a system hook 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 partial 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); /// /// The whole point of the background process, as a test rather than as a promise. /// /// /// The agent must not load the WPF rendering stack. It is checked on the running /// process, not on its references: a reference costs nothing, a load costs the /// hundred megabytes the split exists to avoid. /// /// UI Automation and the assemblies behind it — WindowsBase and PresentationCore — /// are deliberately not in the pattern: the caret fallback pulls them in on purpose /// and only when it runs. What must never appear is the renderer itself. /// [Fact] public void The_agent_runs_without_the_rendering_stack() { using Launch launch = Launch.Start(StartupLaunch.Argument); Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself"); launch.Process.Refresh(); var loaded = launch.Process.Modules .Cast() .Select(module => module.ModuleName) .Where(name => RenderingStack().IsMatch(name)) .ToList(); Assert.True(loaded.Count == 0, $"the agent loaded {string.Join(", ", loaded)}"); } [GeneratedRegex("PresentationFramework|wpfgfx|milcore|PresentationNative", RegexOptions.IgnoreCase)] private static partial Regex RenderingStack(); /// /// Started by the user, the application shows the settings window — which lives in /// a process of its own and is started by the agent. /// [Fact] public void A_launch_by_the_user_opens_the_settings_window() { using Launch launch = Launch.Start(); Assert.NotEqual(IntPtr.Zero, launch.WaitForSettingsWindow()); 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 agent ended by itself"); Assert.Empty(Launch.SettingsProcesses()); } // A second run raises no second agent but asks the running one for the window [Fact] public void The_second_run_ends_by_itself() { using Launch launch = Launch.Start(StartupLaunch.Argument); Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself"); 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); } /// /// Closing the settings window ends that process and leaves the agent alone. /// /// /// This is the behaviour the split was for. The window used to hide itself into the /// tray, because closing it would have thrown away a visual tree the background half /// was still using; now there is nothing shared to throw away, and the memory the /// window took goes back to the system. /// [Fact] public void Closing_the_settings_window_leaves_the_agent_running() { using Launch launch = Launch.Start(); launch.WaitForSettingsWindow(); Process settings = Launch.SettingsProcesses().Single(); try { Assert.True(settings.CloseMainWindow(), "the window did not accept the request to close"); Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived its own closing"); } finally { settings.Dispose(); } Assert.False(launch.Process.HasExited); } /// /// "Exit" in the tray menu ends the application whole: the settings window goes with /// the agent instead of staying on the screen belonging to nothing. /// /// /// The menu itself is out of reach of a test — it is a TrackPopupMenuEx menu /// with a modal loop of its own — so what is checked is the request the menu makes. /// The settings window here is the real one, started by the agent, and it must be /// listening by the time it is on the screen. /// [Fact] public void The_agents_exit_closes_the_settings_window() { using Launch launch = Launch.Start(); launch.WaitForSettingsWindow(); Process settings = Launch.SettingsProcesses().Single(); try { Assert.True(SettingsCloseSignal.RequestClose(), "the settings window was not listening"); Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived the agent's exit"); } finally { settings.Dispose(); } } /// 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 agent 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 || SettingsProcesses().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 agent 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)!; } /// The settings window processes running right now, if any. internal static Process[] SettingsProcesses() => Process.GetProcessesByName("CursorLang.Settings"); /// /// Waits for the settings window. It belongs to another process now, so the /// wait is for that process to appear and put a window on the screen. /// internal IntPtr WaitForSettingsWindow() { DateTime deadline = DateTime.UtcNow + StartTimeout; while (DateTime.UtcNow < deadline) { Process.Refresh(); if (Process.HasExited) { Assert.Fail($"The agent exited while starting with code {Process.ExitCode}"); } foreach (Process settings in SettingsProcesses()) { settings.Refresh(); IntPtr window = settings.MainWindowHandle; settings.Dispose(); if (window != IntPtr.Zero) { return window; } } 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() { foreach (Process settings in SettingsProcesses()) { Kill(settings); } Kill(Process); } // The exit lives in a tray menu no test can reach, and the settings are saved // as they change, so nothing is lost by ending the processes outright private static void Kill(Process process) { try { if (!process.HasExited) { process.Kill(entireProcessTree: true); process.WaitForExit(ExitTimeout); } } catch (InvalidOperationException) { // The process has already ended on its own } finally { process.Dispose(); } } } }