File: TrayControllerPinPruningTests.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 Aspire.Tray.Tests.Helpers;
using static Aspire.Tray.Tests.Helpers.TestAppHostClient;
 
namespace Aspire.Tray.Tests;
 
public class TrayControllerPinPruningTests
{
    public static bool SupportsRestrictedUnixDirectory => !OperatingSystem.IsWindows() && Environment.UserName != "root";
 
    [Fact]
    public async Task FirstLiveSnapshotPrunesMissingPinnedFilesAndFoldersDurably()
    {
        using var directory = new TestTrayStateDirectory();
        var retained = directory.CreateAppHost("retained/apphost.cs");
        var missingFile = directory.CreateAppHost("missing-file/apphost.cs");
        var missingFolder = directory.CreateAppHost("missing-folder/apphost.cs");
        var recent = directory.CreateAppHost("recent/apphost.cs");
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty.Remember(retained).SetPinned(retained, true)
            .Remember(missingFile).SetPinned(missingFile, true)
            .Remember(missingFolder).SetPinned(missingFolder, true).Remember(recent));
        File.Delete(missingFile);
        Directory.Delete(Path.GetDirectoryName(missingFolder)!, recursive: true);
        File.Delete(recent);
        var client = new TestAppHostClient();
        var controller = new TrayController(client, new FileTraySavedStateStore(directory.StatePath));
        await using var lifetime = controller.ConfigureAwait(true);
 
        Assert.Equal(3, controller.State.AppHosts.Count);
        controller.PruneMissingPinnedAppHosts();
        Assert.Equal(3, controller.State.AppHosts.Count);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
 
        Assert.Equal(new AppHostInfo(retained, 0, null).Id, Assert.Single(controller.State.AppHosts).Id);
        Assert.Equal(new AppHostInfo(recent, 0, null).Id, Assert.Single(controller.State.RecentAppHosts).Id);
        Assert.Equal("Stopped", controller.State.AppHosts[0].Subtitle);
        Assert.False(controller.State.ShowStatus);
        var reloaded = new FileTraySavedStateStore(directory.StatePath).Load();
        Assert.Equal([new SavedAppHost(recent, false, true), new SavedAppHost(retained, true, true)], reloaded.AppHosts);
        Assert.True(File.Exists(retained));
        Assert.Empty(client.StartRequests);
    }
 
    [Fact]
    public async Task SubsequentLiveRefreshPrunesDeletedPinsButDisconnectDoesNot()
    {
        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();
        var controller = new TrayController(client, store);
        await using var lifetime = controller.ConfigureAwait(true);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        File.Delete(path);
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Disconnected));
        controller.PruneMissingPinnedAppHosts();
 
        Assert.True(Assert.Single(controller.State.AppHosts).IsPinned);
        Assert.Equal([new SavedAppHost(path, true, true)], new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
 
        Assert.Empty(controller.State.AppHosts);
        Assert.Empty(controller.State.RecentAppHosts);
        Assert.Empty(new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        Assert.False(controller.State.CanClearRecent);
        Assert.Equal("No AppHosts running", controller.State.Status);
    }
 
    [Fact]
    public async Task LiveDiscoveredAppHostsRemainPinnedEvenWhenTheirSourceWasDeletedBeforeTrayStartup()
    {
        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));
        File.Delete(path);
        var client = new TestAppHostClient();
        var controller = new TrayController(client, store);
        await using var lifetime = controller.ConfigureAwait(true);
        controller.Start();
        var host = Host(42) with { AppHostPath = path, Health = AppHostHealth.Healthy };
        await client.PublishAndWaitAsync(controller, new([host], DiscoveryState.Live));
        controller.PruneMissingPinnedAppHosts();
 
        var row = Assert.Single(controller.State.AppHosts);
        Assert.Equal(host.Id, row.Id);
        Assert.True(row.IsPinned);
        Assert.True(row.IsRunning);
        Assert.Equal(AppHostHealth.Healthy, row.Health);
        Assert.Equal([new SavedAppHost(path, true, true)], new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Disconnected));
        Assert.Equal(host.Id, Assert.Single(controller.State.AppHosts).Id);
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
 
        Assert.Empty(controller.State.AppHosts);
        Assert.Empty(controller.State.RecentAppHosts);
        Assert.Empty(new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
    }
 
    [Fact]
    public async Task ExplicitPruningRemovesDeletedPinsWithoutRepeatedChangeNotifications()
    {
        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();
        var controller = new TrayController(client, store);
        await using var lifetime = controller.ConfigureAwait(true);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        File.Delete(path);
        var notifications = 0;
        controller.Changed += () => Interlocked.Increment(ref notifications);
 
        controller.PruneMissingPinnedAppHosts();
 
        Assert.Empty(controller.State.AppHosts);
        Assert.Empty(controller.State.RecentAppHosts);
        Assert.Empty(new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        Assert.Equal(1, notifications);
        controller.PruneMissingPinnedAppHosts();
        Assert.Equal(1, notifications);
    }
 
    [Fact]
    public async Task MissingUnpinnedRecentRequiresExplicitRemoval()
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("apphost.cs");
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty.Remember(path));
        File.Delete(path);
        var client = new TestAppHostClient();
        var controller = new TrayController(client, store);
        await using var lifetime = controller.ConfigureAwait(true);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        controller.PruneMissingPinnedAppHosts();
 
        Assert.Throws<FileNotFoundException>(() => controller.RequestStart(path));
        Assert.Equal(new AppHostInfo(path, 0, null).Id, Assert.Single(controller.State.RecentAppHosts).Id);
        Assert.Equal([new SavedAppHost(path, false, true)], new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        Assert.Empty(client.StartRequests);
        controller.RemoveRecent(path);
        Assert.Empty(controller.State.RecentAppHosts);
        Assert.Empty(new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
    }
 
    [Fact]
    public async Task PendingAndSuccessfulStartsProtectPinsUntilDiscoveryObservesThem()
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("apphost.cs");
        var store = new MemoryTraySavedStateStore();
        store.Save(TraySavedState.Empty.Remember(path).SetPinned(path, true));
        var client = new TestAppHostClient();
        var controller = new TrayController(client, store);
        await using var lifetime = controller.ConfigureAwait(true);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        controller.RequestStart(path);
        await client.NextStartAsync();
        File.Delete(path);
        controller.PruneMissingPinnedAppHosts();
        Assert.True(Assert.Single(controller.State.AppHosts).IsStarting);
        Assert.Equal([new SavedAppHost(path, true, true)], store.Load().AppHosts);
        var previous = controller.State;
        client.CompleteStart(path, new(StartOutcome.Started, 0));
        await WaitForStateAsync(controller, state => !ReferenceEquals(previous, state));
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        Assert.True(Assert.Single(controller.State.AppHosts).IsStarting);
        var host = Host(42) with { AppHostPath = path };
        await client.PublishAndWaitAsync(controller, new([host], DiscoveryState.Live));
        Assert.Equal(host.Id, Assert.Single(controller.State.AppHosts).Id);
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
 
        Assert.Empty(controller.State.AppHosts);
        Assert.Empty(controller.State.RecentAppHosts);
        Assert.Empty(store.Load().AppHosts);
    }
 
    [Fact(Skip = "Requires a non-root Unix user.", SkipUnless = nameof(SupportsRestrictedUnixDirectory))]
    public async Task PermissionErrorsPreservePinsAndReportTheProbeFailure()
    {
        if (OperatingSystem.IsWindows())
        {
            return;
        }
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("restricted/apphost.cs");
        var sourceDirectory = Path.GetDirectoryName(path)!;
        var originalMode = File.GetUnixFileMode(sourceDirectory);
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty.Remember(path).SetPinned(path, true));
        var client = new TestAppHostClient();
        var controller = new TrayController(client, store);
        await using var lifetime = controller.ConfigureAwait(true);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        try
        {
            File.SetUnixFileMode(sourceDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite);
            Assert.Throws<UnauthorizedAccessException>(() => TrayAppHostPath.IsMissing(path));
 
            controller.PruneMissingPinnedAppHosts();
 
            Assert.True(Assert.Single(controller.State.AppHosts).IsPinned);
            Assert.Equal("Unable to check saved AppHost paths. Unavailable pins were kept.", controller.State.Status);
            Assert.True(controller.State.ShowStatus);
            Assert.Equal([new SavedAppHost(path, true, true)], new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
            Assert.Throws<UnauthorizedAccessException>(() => controller.RequestStart(path));
            Assert.Empty(client.StartRequests);
        }
        finally
        {
            File.SetUnixFileMode(sourceDirectory, originalMode);
        }
        controller.PruneMissingPinnedAppHosts();
        Assert.True(Assert.Single(controller.State.AppHosts).IsPinned);
        Assert.False(controller.State.ShowStatus);
    }
 
    [Fact(Skip = "Requires a non-root Unix user.", SkipUnless = nameof(SupportsRestrictedUnixDirectory))]
    public async Task InaccessibleRecentAppHostIsNotReportedAsDeleted()
    {
        if (OperatingSystem.IsWindows())
        {
            return;
        }
        using var directory = new TestTrayStateDirectory();
        var restricted = directory.CreateAppHost("restricted/apphost.cs");
        var deleted = directory.CreateAppHost("deleted/apphost.cs");
        var sourceDirectory = Path.GetDirectoryName(restricted)!;
        var originalMode = File.GetUnixFileMode(sourceDirectory);
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty.Remember(restricted).Remember(deleted));
        File.Delete(deleted);
        try
        {
            File.SetUnixFileMode(sourceDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite);
            var controller = new TrayController(new TestAppHostClient(), store);
            await using var lifetime = controller.ConfigureAwait(true);
 
            Assert.Equal("AppHost source path unavailable",
                Assert.Single(controller.State.RecentAppHosts, host => host.Id == new AppHostInfo(restricted, 0, null).Id).Subtitle);
            Assert.Equal("AppHost source file not found",
                Assert.Single(controller.State.RecentAppHosts, host => host.Id == new AppHostInfo(deleted, 0, null).Id).Subtitle);
        }
        finally
        {
            File.SetUnixFileMode(sourceDirectory, originalMode);
        }
    }
 
    [Fact]
    public async Task PruningDoesNotOverwriteCorruptExternalEditsOrClaimThePinWasRemoved()
    {
        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();
        var controller = new TrayController(client, store);
        await using var lifetime = controller.ConfigureAwait(true);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        directory.WriteState("{externally corrupted");
        File.Delete(path);
 
        controller.PruneMissingPinnedAppHosts();
 
        Assert.True(Assert.Single(controller.State.AppHosts).IsPinned);
        Assert.Empty(controller.State.RecentAppHosts);
        Assert.Equal("Unable to save AppHost history. The saved file was left unchanged.", controller.State.Status);
        Assert.True(controller.State.ShowStatus);
        Assert.Equal("{externally corrupted", File.ReadAllText(directory.StatePath));
        controller.RequestStart(path);
        Assert.Empty(client.StartRequests);
        Assert.Equal("{externally corrupted", File.ReadAllText(directory.StatePath));
    }
}