290 lines
10 KiB
C#
290 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The application as a whole: the agent starting, the settings window it opens, the
|
|
/// single instance and the exit.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public sealed partial class EndToEndTests
|
|
{
|
|
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
|
|
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
|
|
|
|
/// <summary>How long "the application went on working" is worth watching for.</summary>
|
|
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
|
|
|
|
/// <summary>
|
|
/// The whole point of the background process, as a test rather than as a promise.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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<ProcessModule>()
|
|
.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();
|
|
|
|
/// <summary>
|
|
/// Started by the user, the application shows the settings window — which lives in
|
|
/// a process of its own and is started by the agent.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closing the settings window ends that process and leaves the agent alone.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>A started application that shuts down together with the check.</summary>
|
|
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; }
|
|
|
|
/// <summary>Starts the agent first — making sure the place is free.</summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>Starts the agent the way the user — or Windows — does.</summary>
|
|
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)!;
|
|
}
|
|
|
|
/// <summary>The settings window processes running right now, if any.</summary>
|
|
internal static Process[] SettingsProcesses() => Process.GetProcessesByName("CursorLang.Settings");
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<AssemblyMetadataAttribute>()
|
|
.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();
|
|
}
|
|
}
|
|
}
|
|
}
|