diff --git a/CursorLang.Tests/EndToEndTests.cs b/CursorLang.Tests/EndToEndTests.cs
index 1af88e6..a5d3bde 100644
--- a/CursorLang.Tests/EndToEndTests.cs
+++ b/CursorLang.Tests/EndToEndTests.cs
@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text;
namespace CursorLang.Tests;
@@ -14,6 +16,9 @@ namespace CursorLang.Tests;
///
/// 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
{
@@ -59,6 +64,16 @@ public sealed class EndToEndTests
/// 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; }
@@ -66,6 +81,11 @@ public sealed class EndToEndTests
/// Starts the application first — making sure the place is free.
internal static Launch Start()
{
+ 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");
@@ -116,6 +136,25 @@ public sealed class EndToEndTests
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()
diff --git a/CursorLang.Tests/Views/LayoutPopupWindowTests.cs b/CursorLang.Tests/Views/LayoutPopupWindowTests.cs
index 1a4b8ee..e94576a 100644
--- a/CursorLang.Tests/Views/LayoutPopupWindowTests.cs
+++ b/CursorLang.Tests/Views/LayoutPopupWindowTests.cs
@@ -124,9 +124,7 @@ public sealed class LayoutPopupWindowTests
{
var settings = new AppSettings
{
- PlacementMode = PopupPlacementMode.FixedPoint,
- ScreenPosition = position,
- ScreenMargin = 24,
+ PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = position, ScreenMargin = 24,
};
using var popup = Popup.Create(settings);
@@ -160,9 +158,7 @@ public sealed class LayoutPopupWindowTests
{
var settings = new AppSettings
{
- PlacementMode = PopupPlacementMode.AtCursor,
- CursorSide = AnchorSide.BottomRight,
- CursorOffset = 16,
+ PlacementMode = PopupPlacementMode.AtCursor, CursorSide = AnchorSide.BottomRight, CursorOffset = 16,
};
using var popup = Popup.Create(settings);
@@ -213,23 +209,48 @@ public sealed class LayoutPopupWindowTests
{
var settings = new AppSettings
{
- PlacementMode = PopupPlacementMode.AtCaret,
- CaretSide = AnchorSide.BottomRight,
- CaretOffset = 8,
+ PlacementMode = PopupPlacementMode.AtCaret, CaretSide = AnchorSide.BottomRight, CaretOffset = 8,
};
using var popup = Popup.Create(settings);
popup.ViewModel.ShortName = "RU";
- PopupWindowNative.Point before = PopupWindowNative.GetCursorPosition();
- popup.Window.ShowPopup();
+ PopupWindowNative.Point before = default;
+
+ for (int attempt = 0; attempt < 10; attempt++)
+ {
+ if (CaretNative.TryGetCaretRect() is not null)
+ {
+ Assert.Skip("The foreground window reported a caret of its own");
+ }
+
+ before = PopupWindowNative.GetCursorPosition();
+ popup.Window.ShowPopup();
+ PopupWindowNative.Point after = PopupWindowNative.GetCursorPosition();
+
+ if (before.X == after.X && before.Y == after.Y)
+ {
+ break;
+ }
+
+ if (attempt == 9)
+ {
+ Assert.Skip("The cursor kept moving the whole time");
+ }
+ }
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
+ double scale = PopupWindowNative.GetScaleAt(before);
- // A gentle check: a window holding the input focus may still have a caret
- Assert.True(bounds.Right > bounds.Left);
- Assert.True(bounds.Bottom > bounds.Top);
- Assert.NotEqual(default, before);
+ PopupWindowNative.Point expected = PopupLayout.NearAnchor(
+ PopupLayout.AsAnchor(before),
+ settings.CaretSide,
+ PopupLayout.ToPixels(settings.CaretOffset, scale),
+ bounds.Right - bounds.Left,
+ bounds.Bottom - bounds.Top);
+
+ Assert.Equal(expected.X, bounds.Left);
+ Assert.Equal(expected.Y, bounds.Top);
});
}
@@ -266,8 +287,7 @@ public sealed class LayoutPopupWindowTests
{
var settings = new AppSettings
{
- PlacementMode = PopupPlacementMode.FixedPoint,
- ScreenPosition = ScreenPosition.TopLeft,
+ PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = ScreenPosition.TopLeft,
};
using var popup = Popup.Create(settings);
diff --git a/README.RU.md b/README.RU.md
index d61735a..8f30840 100644
--- a/README.RU.md
+++ b/README.RU.md
@@ -147,9 +147,11 @@ dotnet test --collect:"XPlat Code Coverage" --settings CursorLang.Tests\coverage
Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём —
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
-остаётся текстовой заглушкой и сборка на ней падает. Работать runner должен
-в интерактивном сеансе рабочего стола — тесты поднимают настоящие окна,
-а службе в нулевом сеансе ждать нечего.
+остаётся текстовой заглушкой и сборка на ней падает. Работать runner лучше
+в интерактивном сеансе рабочего стола: тесты поднимают настоящие окна, и те
+проверки, которым нужен свой рабочий стол — сквозные, а также те, что просят
+окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
+сеансе, пропускают себя: показать окно там негде.
Пакет, который несёт релиз, загружается в Partner Center как есть. Identity
берётся из переменных репозитория, а если те не заданы — из значений по
diff --git a/README.md b/README.md
index 42c057d..02b2b21 100644
--- a/README.md
+++ b/README.md
@@ -145,9 +145,11 @@ say in it.
Both pipelines ask for a Windows runner labelled `windows-x64` with the .NET 10
SDK and git-lfs on it. The checkout pulls LFS files: without them the icon is a
-text pointer and the build fails on it. The runner has to work in an interactive
-desktop session — the tests raise real windows, and a service in session 0 has
-nothing for them to wait for.
+text pointer and the build fails on it. The runner is better off working in an
+interactive desktop session: the tests raise real windows, and the checks that
+need a desktop of their own — the end-to-end ones, and those that ask for the
+foreground window or the caret — skip themselves on a runner that lives as a
+service in session 0, where there is no desktop to show a window on.
The package the release carries goes to Partner Center as it is. The identity
comes from repository variables and falls back to the defaults of the script when