File: LinuxTrayTests.cs
Web Access
Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using Aspire.Shared;
using Aspire.Tests.Utils;
using Aspire.Tray.Tests.Helpers;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Tray.Tests;
 
public class LinuxTrayTests
{
    public static bool SupportsLinux => OperatingSystem.IsLinux();
 
    [Theory]
    [InlineData("GNOME", "gnome", true)]
    [InlineData("ubuntu:GNOME", "ubuntu", true)]
    [InlineData("gnome-classic", null, true)]
    [InlineData(null, "GNOME", true)]
    [InlineData("KDE", "gnome", false)]
    [InlineData("XFCE", "xfce", false)]
    [InlineData("sway", "sway", false)]
    [InlineData("COSMIC", "cosmic", false)]
    [InlineData(null, null, false)]
    public void OnlyGnomeKeepsTheNativeMenu(string? desktop, string? session, bool nativeMenu)
    {
        Assert.Equal(nativeMenu, LinuxDesktopEnvironment.IsGnome(desktop, session));
    }
 
    [Theory]
    [InlineData("Typical")]
    [InlineData("Empty")]
    [InlineData("RecentOnly")]
    [InlineData("Disconnected")]
    [InlineData("Errors")]
    public Task NativeMenuUsesTheSharedPanelHierarchy(string sample)
    {
        var state = sample switch
        {
            "Typical" => TrayPanelSamples.Typical(),
            "Empty" => TrayPanelSamples.Empty() with { RecentAppHosts = [], CanClearRecent = false },
            "RecentOnly" => TrayPanelSamples.Empty(),
            "Disconnected" => TrayPanelSamples.Disconnected(),
            "Errors" => TrayPanelSamples.Errors(),
            _ => throw new ArgumentOutOfRangeException(nameof(sample))
        };
        var description = new StringBuilder();
        Append(LinuxTrayMenu.Build(state, []), 0);
 
        return Verifier.Verify(description.ToString(), "txt").UseDirectory("Snapshots").UseParameters(sample);
 
        void Append(IReadOnlyList<LinuxTrayMenuItem> items, int depth)
        {
            foreach (var item in items)
            {
                description.Append(' ', depth * 2);
                description.Append(item.Action == LinuxTrayAction.Separator ? "---"
                    : $"{item.Label.Replace('\\', '/')}{(item.Enabled ? "" : " [disabled]")}");
                description.AppendLine();
                if (item.Children is not null)
                {
                    Append(item.Children, depth + 1);
                }
            }
        }
    }
 
    [Fact]
    public void SectionsGroupByInstanceWithoutDuplicatingRunningPins()
    {
        var offline = TrayPanelSamples.Offline(Path.GetFullPath("Offline.AppHost/apphost.cs"));
        var running = TrayPanelSamples.Running(Path.GetFullPath("Running.AppHost/apphost.cs"), 42) with { IsPinned = true };
        var state = new TrayViewState(DiscoveryState.Live, [offline, running], "One AppHost");
 
        var menu = LinuxTrayMenu.Build(state, []);
 
        Assert.Equal([running, offline], menu.Where(item => item.Host is not null).Select(item => item.Host));
        Assert.Equal(["RUNNING", "PINNED"], menu.Where(item => item.Label is "RUNNING" or "PINNED").Select(item => item.Label));
        Assert.All(menu.Where(item => item.Label is "RUNNING" or "PINNED"), item =>
        {
            Assert.False(item.Enabled);
            Assert.Equal(LinuxTrayAction.None, item.Action);
            Assert.Null(item.Children);
        });
        Assert.Equal([LinuxTrayAction.Settings, LinuxTrayAction.Documentation, LinuxTrayAction.Quit],
            menu.TakeLast(3).Select(item => item.Action));
    }
 
    [Theory]
    [InlineData(500, 15, 320, 34)]
    [InlineData(20, 15, 4, 34)]
    [InlineData(980, 15, 636, 34)]
    [InlineData(500, 820, 320, 616)]
    public void FlyoutPlacementFollowsTheIconAndStaysInsideTheWorkarea(double anchorX, double anchorY, double x, double y)
    {
        var bounds = LinuxTrayPanel.Place(new(0, 30, 1000, 770), anchorX, anchorY, 180, 1);
 
        Assert.Equal(new PanelRect(x, y, TrayPanel.Width, 180), bounds);
    }
 
    [Fact]
    public void FlyoutPlacementSupportsMonitorsWithNegativeCoordinates()
    {
        var bounds = LinuxTrayPanel.Place(new(-1920, 30, 1920, 1050), -900, 15, 300, 1);
 
        Assert.Equal(new PanelRect(-1080, 34, TrayPanel.Width, 300), bounds);
    }
 
    [Theory]
    [InlineData(-1, 1)]
    [InlineData(96, 1)]
    [InlineData(120, 1.25)]
    [InlineData(139.2, 1.45)]
    [InlineData(163.2, 1.7)]
    [InlineData(192, 2)]
    public void FlyoutScaleUsesGtkFontResolution(double resolution, double expected)
    {
        Assert.Equal(expected, LinuxTrayPanel.GetScale(resolution), precision: 10);
    }
 
    [Theory]
    [InlineData(0)]
    [InlineData(-96)]
    [InlineData(double.NaN)]
    [InlineData(double.PositiveInfinity)]
    public void InvalidDesktopResolutionFailsExplicitly(double resolution)
    {
        Assert.Throws<InvalidOperationException>(() => LinuxTrayPanel.GetScale(resolution));
    }
 
    [Theory]
    [InlineData(1)]
    [InlineData(1.25)]
    [InlineData(1.45)]
    [InlineData(1.7)]
    [InlineData(2)]
    public void ScaledFlyoutPlacementKeepsMarginsAndFitsTheWorkarea(double scale)
    {
        var workarea = new PanelRect(-1920, 30, 1920, 1050);
        var bounds = LinuxTrayPanel.Place(workarea, -20, workarea.Bottom, 300, scale);
 
        Assert.Equal(TrayPanel.Width * scale, bounds.Width);
        Assert.Equal(300 * scale, bounds.Height);
        Assert.Equal(workarea.Right - 4 * scale, bounds.Right, precision: 10);
        Assert.Equal(workarea.Bottom - 4 * scale, bounds.Bottom, precision: 10);
    }
 
    [Theory]
    [InlineData(1)]
    [InlineData(1.25)]
    [InlineData(1.45)]
    [InlineData(1.7)]
    [InlineData(2)]
    public void ScaledAccessibleBoundsAndPointerCoordinatesMatchTheScene(double scale)
    {
        var geometry = new PanelRect(-1920, 30, 1920, 1050);
        var panel = LinuxTrayPanel.Place(geometry, -900, 15, 300, scale);
        var target = new PanelRect(20, 100, 64, 30);
        var control = LinuxPanelAccessibility.PlaceControl(target, panel.X - geometry.X, panel.Y - geometry.Y, scale);
 
        var (x, y) = LinuxTrayPanel.ToPanelPoint(panel, geometry, control.CenterX, control.CenterY, scale);
 
        Assert.Equal(target.CenterX, x, precision: 10);
        Assert.Equal(target.CenterY, y, precision: 10);
        Assert.Equal(target.Width * scale, control.Width);
        Assert.Equal(target.Height * scale, control.Height);
        Assert.True(target.Contains(x, y));
    }
 
    [Theory]
    [InlineData(0xFF52u, false, "Up")]
    [InlineData(0xFF54u, false, "Down")]
    [InlineData(0xFF09u, false, "Tab")]
    [InlineData(0xFF09u, true, "ShiftTab")]
    [InlineData(0xFE20u, false, "ShiftTab")]
    [InlineData(0xFF0Du, false, "Enter")]
    [InlineData(0xFF8Du, false, "Enter")]
    [InlineData(0x20u, false, "Space")]
    [InlineData(0xFF1Bu, false, "Escape")]
    [InlineData(0xFF50u, false, "Home")]
    [InlineData(0xFF57u, false, "End")]
    [InlineData(0x61u, false, null)]
    public void FlyoutKeysUseTheSharedPanelNavigation(uint key, bool shift, string? expected)
    {
        Assert.Equal(expected is null ? null : Enum.Parse<PanelKey>(expected), LinuxTrayPanel.MapKey(key, shift));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(true, true)]
    public async Task StartupAcknowledgesTheUiLoopIndependentlyOfHostAvailability(bool watcherConnected, bool hostRegistered)
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
 
        Assert.False(application.ReadyAsync(CancellationToken.None).IsCompleted);
        application.UpdateAvailability(watcherConnected, hostRegistered);
        Assert.False(application.ReadyAsync(CancellationToken.None).IsCompleted);
        application.CompleteInitialization();
 
        Assert.Equal(watcherConnected && hostRegistered, application.IsAvailable);
        Assert.True(application.ReadyAsync(CancellationToken.None).IsCompletedSuccessfully);
    }
 
    [Fact]
    public async Task HostLossRevokesAvailabilityWithoutDisconnectingTheWatcher()
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
        application.UpdateAvailability(watcherConnected: true, hostRegistered: true);
        application.CompleteInitialization();
        await application.ReadyAsync(CancellationToken.None);
 
        application.UpdateAvailability(watcherConnected: true, hostRegistered: false);
        Assert.False(application.IsAvailable);
        var restore = application.RestoreAsync(CancellationToken.None);
        application.ProcessPending();
        await restore;
        Assert.False(application.IsAvailable);
 
        application.UpdateAvailability(watcherConnected: true, hostRegistered: true);
        Assert.True(application.IsAvailable);
 
        application.UpdateAvailability(watcherConnected: false, hostRegistered: true);
        Assert.False(application.IsAvailable);
    }
 
    [Fact]
    public async Task RepeatedStartRestoresAvailabilityWithoutOpeningAPanel()
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
        application.UpdateAvailability(watcherConnected: true, hostRegistered: true);
        application.CompleteInitialization();
 
        var restored = application.RestoreAsync(CancellationToken.None);
        application.ProcessPending();
        await restored;
 
        Assert.True(application.IsAvailable);
    }
 
    [Fact]
    public async Task RestoreDuringShutdownCompletesWithAnError()
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
        application.UpdateAvailability(watcherConnected: true, hostRegistered: true);
        application.CompleteInitialization();
        var restored = application.RestoreAsync(CancellationToken.None);
        application.RequestQuit();
 
        application.ProcessPending();
 
        await Assert.ThrowsAsync<InvalidOperationException>(() => restored);
    }
 
    [Fact]
    public async Task StopIsAcknowledgedWhileWaitingForTheFirstHost()
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        var pipe = $"at-{Guid.NewGuid():N}";
        var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        await using var activation = new TrayActivation(pipe, application.RestoreAsync, application.ReadyAsync, () =>
        {
            application.RequestQuit();
            stopped.TrySetResult();
        });
        application.UpdateAvailability(watcherConnected: false, hostRegistered: false);
        application.CompleteInitialization();
 
        var identity = await TrayActivation.StopExistingAsync(pipe, timeout.Token);
        await stopped.Task.WaitAsync(timeout.Token);
 
        Assert.Equal(Environment.ProcessId, identity.ProcessId);
        Assert.False(application.IsAvailable);
        var restored = application.RestoreAsync(timeout.Token);
        application.ProcessPending();
        var error = await Assert.ThrowsAsync<InvalidOperationException>(() => restored);
        Assert.Equal("The tray is shutting down.", error.Message);
    }
 
    [Fact]
    public async Task FirstHostCanAppearAfterStartupAndRepeatedStart()
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
        application.UpdateAvailability(watcherConnected: false, hostRegistered: false);
        application.CompleteInitialization();
        await application.ReadyAsync(CancellationToken.None);
 
        var restore = application.RestoreAsync(CancellationToken.None);
        application.ProcessPending();
        await restore;
        Assert.False(application.IsAvailable);
 
        application.UpdateAvailability(watcherConnected: true, hostRegistered: false);
        Assert.False(application.IsAvailable);
        application.UpdateAvailability(watcherConnected: true, hostRegistered: true);
        Assert.True(application.IsAvailable);
    }
 
    [Fact]
    public void MissingProjectAndExistingProjectAreDistinguished()
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("apphost.cs");
 
        Assert.True(LinuxTrayApplication.ProjectFileExists(path));
        File.Delete(path);
        Assert.False(LinuxTrayApplication.ProjectFileExists(path));
    }
 
    [Fact(Skip = "Uses Unix directory permissions.", SkipUnless = nameof(SupportsLinux))]
    [SupportedOSPlatform("linux")]
    public async Task InaccessibleProjectsDoNotOfferToRemoveSavedPreferences()
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("restricted/apphost.cs");
        var parent = Path.GetDirectoryName(path)!;
        var mode = File.GetUnixFileMode(parent);
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty.Remember(path));
        await using var controller = new TrayController(new TestAppHostClient(), store);
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
        var host = Assert.Single(controller.State.RecentAppHosts);
        try
        {
            File.SetUnixFileMode(parent, UnixFileMode.UserRead | UnixFileMode.UserWrite);
 
            Assert.Throws<UnauthorizedAccessException>(() => LinuxTrayApplication.ProjectFileExists(path));
            Assert.Throws<UnauthorizedAccessException>(() => application.Execute(new("", LinuxTrayAction.Folder, host)));
            Assert.Equal([new SavedAppHost(path, false, true)], store.Load().AppHosts);
        }
        finally
        {
            File.SetUnixFileMode(parent, mode);
        }
    }
 
    [Fact]
    public async Task PeriodicRefreshPrunesDeletedPinsWithoutDiscoveryUpdates()
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("apphost.cs");
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty.Remember(path).SetPinned(path, true));
        var client = new TestAppHostClient();
        await using var controller = new TrayController(client, store);
        var timeProvider = new FakeTimeProvider();
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings(), timeProvider);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        var displayed = Assert.IsType<TrayViewState>(application.GetStateForRefresh());
        Assert.True(Assert.Single(displayed.AppHosts).IsPinned);
        File.Delete(path);
 
        timeProvider.Advance(TimeSpan.FromMilliseconds(999));
        Assert.Null(application.GetStateForRefresh());
        timeProvider.Advance(TimeSpan.FromMilliseconds(1));
        var refreshed = Assert.IsType<TrayViewState>(application.GetStateForRefresh());
 
        Assert.NotSame(displayed, refreshed);
        Assert.Empty(refreshed.AppHosts);
        Assert.Empty(new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        Assert.Null(application.GetStateForRefresh());
        Assert.Empty(client.StartRequests);
    }
 
    [Fact]
    public async Task PendingShutdownSkipsDialog()
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
        application.RequestQuit();
 
        var response = application.RunDialog(42, _ => throw new InvalidOperationException("Dialog must not open during shutdown."));
 
        Assert.Equal(-6, response);
    }
 
    [Theory]
    [InlineData(-3)]
    [InlineData(-6)]
    [InlineData(-7)]
    public async Task DialogResponseIsCanceledWhenShutdownIsRequested(int dialogResponse)
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
 
        var response = application.RunDialog(42, _ =>
        {
            // Simulate shutdown arriving during the modal loop, before a GTK timer tick.
            application.RequestQuit();
            return dialogResponse;
        });
 
        Assert.Equal(-6, response);
    }
 
    [Theory]
    [InlineData(-3)]
    [InlineData(-6)]
    [InlineData(-7)]
    public async Task DialogResponseIsPreservedWithoutShutdown(int dialogResponse)
    {
        await using var controller = new TrayController(new TestAppHostClient());
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
 
        var response = application.RunDialog(42, _ => dialogResponse);
 
        Assert.Equal(dialogResponse, response);
    }
 
    [Fact(Skip = "Uses Unix absolute path syntax.", SkipUnless = nameof(SupportsLinux))]
    public Task AutostartEscapesDesktopArgumentsAndFieldCodes()
        => Verifier.Verify(LinuxTrayStartupSettings.Serialize("/opt/Aspire \"A & B\"/$100%`back\\slash/aspire"), "txt")
            .UseDirectory("Snapshots");
 
    [Fact]
    public void LiveMenuSurfacesControllerNotices()
    {
        var host = new AppHostMenuItem(new(Path.GetFullPath("apphost.cs"), 1, 2),
            "Example", "PID 1", "Example", true, true, false, null);
        var state = new TrayViewState(DiscoveryState.Live, [host], "One AppHost")
        {
            Notice = "Unable to save AppHost history. The saved file was left unchanged."
        };
 
        var menu = LinuxTrayMenu.Build(state, []);
 
        Assert.Equal("Aspire \u00b7 1 running", menu[0].Label);
        Assert.False(menu[0].Enabled);
        Assert.Equal(state.Notice, menu[1].Label);
        Assert.False(menu[1].Enabled);
        Assert.Same(host, Assert.Single(menu, item => item.Host is not null).Host);
    }
 
    [Fact]
    public void MenuActionsRetainExactIdentity()
    {
        var id = new AppHostId(Path.GetFullPath("Example.AppHost.csproj"), 42, 1000);
        var host = new AppHostMenuItem(id, "Example", "PID 42", "Example", true, true, false, null)
        {
            Health = AppHostHealth.Healthy
        };
        var state = new TrayViewState(DiscoveryState.Live, [host], "One AppHost");
        var application = new LinuxFolderApplication("Visual Studio Code", Path.GetFullPath("code"), ["--"]);
        var menu = LinuxTrayMenu.Build(state, [application]);
        var row = Assert.Single(menu, item => item.Host is not null);
        Assert.Equal("Example \u00b7 Healthy", row.Label);
        Assert.Equal(LinuxTrayAction.None, row.Action);
        Assert.Same(host, row.Host);
        var actions = row.Children!.Where(item => item.Action is not (LinuxTrayAction.None or LinuxTrayAction.Separator)).ToArray();
        Assert.Equal([LinuxTrayAction.Dashboard, LinuxTrayAction.Stop,
            LinuxTrayAction.Folder, LinuxTrayAction.CopyPath, LinuxTrayAction.TogglePin], actions.Select(item => item.Action));
        Assert.All(actions, action => Assert.Equal(id, action.Host!.Id));
        Assert.Equal("Stop", actions[1].Label);
        var openIn = row.Children!.Single(item => item.Label == "Open in");
        var editor = Assert.Single(openIn.Children!);
        Assert.Equal(LinuxTrayAction.OpenIn, editor.Action);
        Assert.Same(application, editor.Application);
        Assert.Equal(id, editor.Host!.Id);
        Assert.Equal(LinuxTrayIcon.Healthy, row.Icon);
        Assert.Equal(LinuxTrayIcon.Documentation, menu.Single(item => item.Action == LinuxTrayAction.Documentation).Icon);
        Assert.Equal(LinuxTrayIcon.Settings, menu.Single(item => item.Action == LinuxTrayAction.Settings).Icon);
    }
 
    [Fact]
    public void MenuLocationsUseCompactPathsForRunningAndRecentHosts()
    {
        var path = Path.GetFullPath("home/developer/worktrees/worktree-a/Shop.AppHost/apphost.cs");
        var host = new AppHostMenuItem(new(path, 42, 1000), "Shop", "PID 42", "Shop", true, true, false, null);
        var recent = host with { Id = new(path, 0, null), IsRunning = false, CanStart = true };
        var state = new TrayViewState(DiscoveryState.Live, [host], "One AppHost") { RecentAppHosts = [recent] };
 
        var menu = LinuxTrayMenu.Build(state, []);
 
        var location = Path.Combine("worktree-a", "Shop.AppHost");
        var row = Assert.Single(menu, item => item.Host is not null);
        Assert.Equal($"{location} \u00b7 PID 42", row.Children![^1].Label);
        var recentRow = Assert.Single(menu.Single(item => item.Label == "Recently opened \u00b7 1").Children!, item => item.Host is not null);
        Assert.Equal(location, recentRow.Children![^1].Label);
        Assert.False(row.Children![^1].Enabled);
        Assert.False(recentRow.Children![^1].Enabled);
        Assert.Equal(path, row.Host!.Id.AppHostPath);
        Assert.Equal(path, recentRow.Host!.Id.AppHostPath);
    }
 
    [Theory]
    [InlineData("worktree\nbranch/Shop\tAppHost", "worktree branch/Shop AppHost")]
    [InlineData("abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstu\u2026EFGHIJKLMNOPQRSTUVWXYZ")]
    public void MenuLocationsAreSingleLineAndMiddleTruncated(string directory, string expected)
    {
        var path = Path.GetFullPath($"{directory}/apphost.cs");
        var host = new AppHostMenuItem(new(path, 42, 1000), "Shop", path, "Shop", true, true, false, null);
 
        var menu = LinuxTrayMenu.Build(new(DiscoveryState.Live, [host], "One AppHost"), []);
 
        var row = Assert.Single(menu, item => item.Host is not null);
        Assert.Equal($"{expected.Replace('/', Path.DirectorySeparatorChar)} \u00b7 PID 42", row.Children![^1].Label);
        Assert.Equal(path, row.Host!.Id.AppHostPath);
    }
 
    [Fact]
    public async Task RemovingOneRecentItemPreservesOtherHistoryAndPins()
    {
        using var directory = new TestTrayStateDirectory();
        var removed = directory.CreateAppHost("removed/apphost.cs");
        var kept = directory.CreateAppHost("kept/apphost.cs");
        var pinned = directory.CreateAppHost("pinned/apphost.cs");
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty.Remember(removed).Remember(kept).Remember(pinned).SetPinned(pinned, true));
        var client = new TestAppHostClient();
        await using var controller = new TrayController(client, store);
        using var application = new LinuxTrayApplication(controller, new MemoryTrayStartupSettings());
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        var menu = LinuxTrayMenu.Build(controller.State, []);
        var recent = menu.Single(item => item.Label == "Recently opened \u00b7 2");
        var row = recent.Children!.Single(item => item.Host?.Id.AppHostPath == removed);
        var action = row.Children!.Single(item => item.Action == LinuxTrayAction.RemoveRecent);
 
        application.Execute(action);
 
        Assert.Equal(kept, Assert.Single(controller.State.RecentAppHosts).Id.AppHostPath);
        Assert.Equal(pinned, Assert.Single(controller.State.AppHosts).Id.AppHostPath);
        Assert.True(File.Exists(removed));
        Assert.Equal([kept, pinned], store.Load().AppHosts.Select(host => host.AppHostPath).Order().ToArray());
        Assert.Empty(client.StartRequests);
        Assert.All(menu.Where(item => item.Host is not null), item =>
            Assert.Equal([LinuxTrayAction.Start, LinuxTrayAction.Separator,
                LinuxTrayAction.Folder, LinuxTrayAction.CopyPath, LinuxTrayAction.TogglePin, LinuxTrayAction.Separator, LinuxTrayAction.None,
                LinuxTrayAction.None], item.Children!.Select(child => child.Action)));
    }
 
    [Fact]
    public void OpenInListsEveryDiscoveredApplicationForRunningAndRecentHosts()
    {
        var applications = new[]
        {
            new LinuxFolderApplication("Visual Studio Code", Path.GetFullPath("code"), ["--"]),
            new LinuxFolderApplication("Konsole", Path.GetFullPath("konsole"), ["--workdir"])
        };
        var state = TrayPanelSamples.Typical();
        var menu = LinuxTrayMenu.Build(state, applications);
        var rows = menu.Where(item => item.Host is not null)
            .Concat(menu.Single(item => item.Label == "Recently opened \u00b7 2").Children!.Where(item => item.Host is not null));
 
        Assert.All(rows, row =>
        {
            var openIn = row.Children!.Single(item => item.Label == "Open in");
            Assert.Equal(applications, openIn.Children!.Select(item => item.Application));
            Assert.All(openIn.Children!, item =>
            {
                Assert.Equal(LinuxTrayAction.OpenIn, item.Action);
                Assert.Same(row.Host, item.Host);
            });
        });
        var withoutApplications = LinuxTrayMenu.Build(state, []);
        Assert.Equal([LinuxTrayAction.Dashboard, LinuxTrayAction.Stop,
            LinuxTrayAction.Separator, LinuxTrayAction.Folder, LinuxTrayAction.CopyPath, LinuxTrayAction.TogglePin,
            LinuxTrayAction.Separator, LinuxTrayAction.None, LinuxTrayAction.None],
            withoutApplications.First(item => item.Host is not null).Children!.Select(item => item.Action));
    }
 
    [Theory]
    [InlineData("Healthy", "Healthy")]
    [InlineData("Warning", "Waiting / degraded")]
    [InlineData("Unhealthy", "Unhealthy")]
    [InlineData("Unknown", "Health unknown")]
    public void HealthIsExposedAsTextWithoutDependingOnHostIconSupport(string health, string expected)
    {
        var host = new AppHostMenuItem(new(Path.GetFullPath("apphost.cs"), 1, 2),
            "Example", "PID 1", "Example", true, true, false, null) { Health = Enum.Parse<AppHostHealth>(health) };
        var menu = LinuxTrayMenu.Build(new(DiscoveryState.Live, [host], ""), []);
        var row = Assert.Single(menu, item => item.Host is not null);
        Assert.Equal($"Example \u00b7 {expected}", row.Label);
        Assert.Equal(Enum.Parse<LinuxTrayIcon>(health), row.Icon);
    }
 
    [Fact]
    public void EmptyAppHostShowsNoResourcesWithANeutralIcon()
    {
        var host = new AppHostMenuItem(new(Path.GetFullPath("apphost.cs"), 1, 2),
            "Example", "PID 1", "Example", true, true, false, null) { Health = AppHostHealth.NoResources };
        var row = Assert.Single(LinuxTrayMenu.Build(new(DiscoveryState.Live, [host], ""), []), item => item.Host is not null);
 
        Assert.Equal("Example \u00b7 No resources", row.Label);
        Assert.Equal(LinuxTrayIcon.Unknown, row.Icon);
        Assert.Equal("No resources", row.Children![^2].Label);
        Assert.Equal("Example \u00b7 Stopped",
            Assert.Single(LinuxTrayMenu.Build(new(DiscoveryState.Live, [host with { IsRunning = false }], ""), []),
                item => item.Host is not null).Label);
        Assert.Equal("Example \u00b7 Discovery unavailable",
            LinuxTrayMenu.Build(new(DiscoveryState.Disconnected, [host], ""), [])
                .Single(item => item.Host == host).Label);
    }
 
    [Fact]
    public void OfflinePinsHaveExplicitStartAndDisconnectedActionsStayDisabled()
    {
        var host = new AppHostMenuItem(new(Path.GetFullPath("apphost.cs"), 0, null),
            "Example", "Stopped", "Example", false, false, false, null)
        {
            IsRunning = false, IsPinned = true, CanStart = true
        };
        var state = new TrayViewState(DiscoveryState.Live, [host], "");
        var row = Assert.Single(LinuxTrayMenu.Build(state, []), item => item.Host is not null);
        Assert.Equal(LinuxTrayAction.None, row.Action);
        Assert.Equal(LinuxTrayAction.Start, row.Children![0].Action);
        Assert.True(row.Children[0].Enabled);
        Assert.Equal(LinuxTrayIcon.Stopped, row.Icon);
        Assert.Equal("Unpin", row.Children.Single(item => item.Action == LinuxTrayAction.TogglePin).Label);
        var disconnected = state with { Discovery = DiscoveryState.Disconnected, AppHosts = [host with { CanStart = false }] };
        row = LinuxTrayMenu.Build(disconnected, []).Single(item => item.Host?.Id == host.Id);
        Assert.Equal("Example \u00b7 Discovery unavailable", row.Label);
        Assert.False(row.Children![0].Enabled);
        Assert.Equal(LinuxTrayIcon.Warning, row.Icon);
    }
 
    [Theory]
    [InlineData(true, false, null, "Warning")]
    [InlineData(false, true, null, "Warning")]
    [InlineData(false, false, "Stop failed", "Unhealthy")]
    public void PendingActionsAndErrorsOverrideHealthyIcons(bool starting, bool stopping, string? error, string expected)
    {
        var host = new AppHostMenuItem(new(Path.GetFullPath("apphost.cs"), 1, 2),
            "Example", "PID 1", "Example", true, true, stopping, error)
        {
            Health = AppHostHealth.Healthy, IsStarting = starting
        };
        var row = LinuxTrayMenu.Build(new(DiscoveryState.Live, [host], ""), [])
            .Single(item => item.Host == host);
        Assert.Equal(Enum.Parse<LinuxTrayIcon>(expected), row.Icon);
    }
 
    [Fact]
    public void AutostartIsOptInAndRefusesExternalEdits()
    {
        using var installation = new TestTrayStartupInstallation();
        LinuxTrayTestPayload.Create(installation.Root, "linux-x64");
        var cli = Path.Combine(installation.Root, "aspire-tray");
        var directory = Path.Combine(installation.Root, "autostart");
        var options = new TrayOptions(cli, null, installation.BundleRoot, cli);
        var settings = new LinuxTrayStartupSettings(options, true, directory);
        Assert.False(settings.Read().Enabled);
        Assert.True(settings.Read().CanEnable);
        Assert.False(Directory.Exists(directory));
        Assert.True(settings.SetEnabled(true).Enabled);
        Assert.True(new LinuxTrayStartupSettings(options, true, directory).Read().Enabled);
        var registration = Path.Combine(directory, LinuxTrayStartupSettings.FileName);
        var modified = File.ReadAllText(registration) + "Hidden=true\n";
        File.WriteAllText(registration, modified);
        Assert.Throws<InvalidOperationException>(() => settings.SetEnabled(false));
        Assert.Equal(modified, File.ReadAllText(registration));
    }
 
    [Fact]
    public void AutostartCanBeDisabledAfterTheInstallationDisappears()
    {
        using var installation = new TestTrayStartupInstallation();
        LinuxTrayTestPayload.Create(installation.Root, "linux-x64");
        var cli = Path.Combine(installation.Root, "aspire-tray");
        var directory = Path.Combine(installation.Root, "autostart");
        var options = new TrayOptions(cli, null, installation.BundleRoot, cli);
        new LinuxTrayStartupSettings(options, true, directory).SetEnabled(true);
        File.Delete(cli);
        var settings = new LinuxTrayStartupSettings(options, true, directory);
        Assert.False(settings.Read().CanEnable);
        Assert.True(settings.Read().Enabled);
        Assert.False(settings.SetEnabled(false).Enabled);
        Assert.Empty(Directory.GetFiles(directory));
    }
 
    [Theory]
    [InlineData("linux-x64")]
    [InlineData("linux-arm64")]
    public void LinuxPayloadRejectsWrongArchitectureAndManagedLoaders(string rid)
    {
        using var installation = new TestTrayStartupInstallation();
        LinuxTrayTestPayload.Create(installation.Root, rid);
        LinuxTrayPayload.Validate(installation.Root, rid);
        Assert.Throws<InvalidDataException>(() => LinuxTrayPayload.Validate(installation.Root,
            rid == "linux-x64" ? "linux-arm64" : "linux-x64"));
        File.WriteAllText(Path.Combine(installation.Root, "aspire-tray.runtimeconfig.json"), "{}");
        Assert.Throws<InvalidDataException>(() => LinuxTrayPayload.Validate(installation.Root, rid));
    }
 
    [Fact(Skip = "Uses Linux flock.", SkipUnless = nameof(SupportsLinux))]
    [SupportedOSPlatform("linux")]
    public void SingletonUsesPrivatePersistentLockAndRejectsLinks()
    {
        using var installation = new TestTrayStartupInstallation();
        var directory = Path.Combine(installation.Root, "runtime");
        using (var first = LinuxTrayRuntime.TryAcquire(directory))
        {
            Assert.NotNull(first);
            Assert.Null(LinuxTrayRuntime.TryAcquire(directory));
            Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, File.GetUnixFileMode(directory));
            Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(Path.Combine(directory, "instance.lock")));
        }
        Assert.True(File.Exists(Path.Combine(directory, "instance.lock")));
        using (var next = LinuxTrayRuntime.TryAcquire(directory))
        {
            Assert.NotNull(next);
        }
        var lockPath = Path.Combine(directory, "instance.lock");
        File.Delete(lockPath);
        File.CreateSymbolicLink(lockPath, installation.MacCli);
        Assert.Throws<Win32Exception>(() => LinuxTrayRuntime.TryAcquire(directory));
    }
 
    [Theory]
    [InlineData(Architecture.X64, false, 0xA0042)]
    [InlineData(Architecture.X64, true, 0xA0442)]
    [InlineData(Architecture.Arm64, false, 0x88042)]
    [InlineData(Architecture.Arm64, true, 0x88442)]
    [SupportedOSPlatform("linux")]
    public void RuntimeFileFlagsUseTheArchitectureSpecificNoFollowFlag(Architecture architecture, bool append, int expected)
    {
        Assert.Equal(expected, LinuxTrayRuntime.GetPrivateFileFlags(architecture, append));
    }
 
    [Theory]
    [InlineData("0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-aspire@autostart.service\n", true)]
    [InlineData("0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-gnome-aspire-1234.scope\n", false)]
    [InlineData("0::/user.slice/user-1000.slice/session-4.scope\n", false)]
    [InlineData("0::/\n", false)]
    [InlineData("12:cpu,cpuacct:/foo.service\n1:name=systemd:/user.slice/session-4.scope\n0::/\n", false)]
    [InlineData("12:cpu,cpuacct:/\n1:name=systemd:/user.slice/user-1000.slice/user@1000.service/app-aspire@autostart.service\n0::/\n", true)]
    [InlineData("", false)]
    [SupportedOSPlatform("linux")]
    public void OnlyTheSystemdHierarchyDecidesWhetherTheTrayIsInAServiceUnit(string cgroup, bool expected)
    {
        Assert.Equal(expected, LinuxSessionBus.IsInServiceUnit(cgroup));
    }
 
    [Fact(Skip = "Uses Unix symbolic links.", SkipUnless = nameof(SupportsLinux))]
    [SupportedOSPlatform("linux")]
    public void RuntimeAllowsAliasedHomesButRejectsASymlinkedRuntimeDirectory()
    {
        using var installation = new TestTrayStartupInstallation();
        var home = Path.Combine(installation.Root, "home");
        var alias = Path.Combine(installation.Root, "alias");
        Directory.CreateDirectory(home);
        Directory.CreateSymbolicLink(alias, home);
        var runtime = Path.Combine(alias, ".aspire", "tray", "runtime");
        using (var owner = LinuxTrayRuntime.TryAcquire(runtime))
        {
            Assert.NotNull(owner);
            Assert.Null(LinuxTrayRuntime.TryAcquire(Path.Combine(home, ".aspire", "tray", "runtime")));
        }
        var linkedRuntime = Path.Combine(installation.Root, "linked-runtime");
        Directory.CreateSymbolicLink(linkedRuntime, runtime);
        var mode = File.GetUnixFileMode(runtime);
 
        Assert.Throws<IOException>(() => LinuxTrayRuntime.TryAcquire(linkedRuntime));
        Assert.Equal(mode, File.GetUnixFileMode(runtime));
    }
 
    [Fact(Skip = "Uses Linux launch paths.", SkipUnless = nameof(SupportsLinux))]
    [SupportedOSPlatform("linux")]
    public void DetachedLaunchPassesPathsAsArguments()
    {
        using var installation = new TestTrayStartupInstallation();
        LinuxTrayTestPayload.Create(Path.Combine(installation.BundleRoot, "tray"), "linux-x64");
        var options = new TrayOptions(installation.MacCli, null, installation.BundleRoot, installation.MacCli);
        var start = LinuxTrayRuntime.CreateStartInfo(options);
        Assert.False(start.UseShellExecute);
        Assert.Equal(Path.Combine(installation.BundleRoot, "tray", "aspire-tray"), start.FileName);
        Assert.Equal(["--detached", "--cli", options.CliPath, "--bundle-root", options.BundleRoot!,
            "--startup-cli", options.StartupCliPath!], start.ArgumentList);
    }
}