File: TrayControllerPreferencesTests.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.Text.Json;
using Aspire.Tray.Tests.Helpers;
using static Aspire.Tray.Tests.Helpers.TestAppHostClient;
 
namespace Aspire.Tray.Tests;
 
public class TrayControllerPreferencesTests
{
    [Fact]
    public async Task StopPreferenceIsSavedEvenWhenHistoryIsUnchangedAndSurvivesRestart()
    {
        using var directory = new TestTrayStateDirectory();
        var client = new TestAppHostClient();
        await using (var controller = new TrayController(client, new FileTraySavedStateStore(directory.StatePath)))
        {
            Assert.True(controller.ConfirmStop);
            controller.SetConfirmStop(false);
            Assert.False(controller.ConfirmStop);
            Assert.False(new FileTraySavedStateStore(directory.StatePath).Load().ConfirmStop);
        }
 
        await using (var restarted = new TrayController(client, new FileTraySavedStateStore(directory.StatePath)))
        {
            Assert.False(restarted.ConfirmStop);
            restarted.SetConfirmStop(true);
            Assert.True(restarted.ConfirmStop);
        }
        await using var final = new TrayController(client, new FileTraySavedStateStore(directory.StatePath));
        Assert.True(final.ConfirmStop);
        Assert.Empty(client.Requests);
        Assert.Empty(client.StartRequests);
    }
 
    [Fact]
    public async Task FailedOptOutKeepsConfirmationEnabledAndCanBeRetried()
    {
        var store = new TestTraySavedStateStore(TraySavedState.Empty) { FailSaves = true };
        await using var controller = new TrayController(new TestAppHostClient(), store);
 
        Assert.Throws<InvalidOperationException>(() => controller.SetConfirmStop(false));
        Assert.True(controller.ConfirmStop);
        Assert.True(store.Load().ConfirmStop);
        Assert.True(controller.State.ShowStatus);
        Assert.Equal("Unable to save AppHost history. The saved file was left unchanged.", controller.State.Status);
        store.FailSaves = false;
        controller.SetConfirmStop(false);
        Assert.False(controller.ConfirmStop);
        Assert.False(store.Load().ConfirmStop);
        Assert.Equal(1, store.SaveCount);
    }
 
    [Fact]
    public async Task UnreadableHistoryCannotDisableStopConfirmation()
    {
        using var directory = new TestTrayStateDirectory();
        directory.WriteState("{broken");
        await using var controller = new TrayController(new TestAppHostClient(), new FileTraySavedStateStore(directory.StatePath));
 
        Assert.True(controller.ConfirmStop);
        Assert.Throws<InvalidOperationException>(() => controller.SetConfirmStop(false));
        Assert.True(controller.ConfirmStop);
        Assert.Equal("{broken", File.ReadAllText(directory.StatePath));
    }
 
    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(10)]
    [InlineData(50)]
    public async Task InitialHistoryIsTrimmedDurablyWithoutLosingPinsOrStopPreference(int limit)
    {
        using var directory = new TestTrayStateDirectory();
        var paths = Enumerable.Range(0, 50).Select(index => directory.CreateAppHost($"project-{index}/apphost.cs")).ToArray();
        var saved = new TraySavedState(paths.Select(path => new SavedAppHost(path, true, true)).ToArray()) { ConfirmStop = false };
        new FileTraySavedStateStore(directory.StatePath).Save(saved);
        await using var controller = new TrayController(new TestAppHostClient(), new FileTraySavedStateStore(directory.StatePath), limit);
 
        var reloaded = new FileTraySavedStateStore(directory.StatePath).Load();
        Assert.False(controller.ConfirmStop);
        Assert.False(reloaded.ConfirmStop);
        Assert.Equal(paths, reloaded.AppHosts.Select(host => host.AppHostPath));
        Assert.All(reloaded.AppHosts, host => Assert.True(host.IsPinned));
        Assert.Equal(paths.Take(limit), reloaded.AppHosts.Where(host => host.IsRecent).Select(host => host.AppHostPath));
        Assert.Equal(limit > 0, controller.State.CanClearRecent);
        Assert.Equal(50, controller.State.AppHosts.Count);
    }
 
    [Fact]
    public async Task OldTwentyRowFileDefaultsToConfirmationAndMigratesToTen()
    {
        using var directory = new TestTrayStateDirectory();
        var saved = new TraySavedState(Enumerable.Range(0, 20)
            .Select(index => new SavedAppHost(Path.GetFullPath($"project-{index}/apphost.cs"), index == 19, true)).ToArray());
        var json = JsonSerializer.SerializeToNode(saved, TraySavedStateJsonContext.Default.TraySavedState)!.AsObject();
        Assert.True(json.Remove("confirmStop"));
        directory.WriteState(json.ToJsonString());
        Assert.True(new FileTraySavedStateStore(directory.StatePath).Load().ConfirmStop);
 
        await using var controller = new TrayController(new TestAppHostClient(), new FileTraySavedStateStore(directory.StatePath));
 
        Assert.True(controller.ConfirmStop);
        var reloaded = new FileTraySavedStateStore(directory.StatePath).Load();
        Assert.True(reloaded.ConfirmStop);
        Assert.Equal(saved.AppHosts.Take(10).Append(saved.AppHosts[^1] with { IsRecent = false }), reloaded.AppHosts);
        Assert.Equal(10, controller.State.RecentAppHosts.Count);
        Assert.True(Assert.Single(controller.State.AppHosts).IsPinned);
    }
 
    [Fact]
    public async Task FailedInitialTrimStillAppliesLimitInMemoryAndPreservesTheStore()
    {
        var paths = Enumerable.Range(0, 20).Select(index => Path.GetFullPath($"project-{index}/apphost.cs")).ToArray();
        var saved = new TraySavedState(paths.Select(path => new SavedAppHost(path, false, true)).ToArray()) { ConfirmStop = false };
        var store = new TestTraySavedStateStore(saved) { FailSaves = true };
        await using var controller = new TrayController(new TestAppHostClient(), store, 1);
 
        Assert.False(controller.ConfirmStop);
        Assert.Single(controller.State.RecentAppHosts);
        Assert.Equal(saved, store.Load());
        Assert.Equal("Unable to save AppHost history. The saved file was left unchanged.", controller.State.Status);
 
        store.FailSaves = false;
        controller.SetConfirmStop(false);
        Assert.False(store.Load().ConfirmStop);
        Assert.Equal(saved.AppHosts.Take(1), store.Load().AppHosts);
        Assert.Equal(1, store.SaveCount);
    }
 
    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(10)]
    [InlineData(50)]
    public async Task DiscoveryUsesConfiguredLimitAndPreservesPreferenceAcrossHistoryActions(int limit)
    {
        using var directory = new TestTrayStateDirectory();
        var paths = Enumerable.Range(0, 52).Select(index => directory.CreateAppHost($"project-{index}/apphost.cs")).ToArray();
        var client = new TestAppHostClient();
        var store = new TestTraySavedStateStore(TraySavedState.Empty);
        await using var controller = new TrayController(client, store, limit);
        controller.SetConfirmStop(false);
        controller.SetPinned(paths[^1], true);
        controller.Start();
        var hosts = paths.Select((path, index) => Host(index + 1) with { AppHostPath = path }).ToArray();
        await client.PublishAndWaitAsync(controller, new(hosts, DiscoveryState.Live));
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
 
        Assert.False(controller.ConfirmStop);
        Assert.False(store.Load().ConfirmStop);
        Assert.Equal(paths.Take(limit).Select(IdentityPath), controller.State.RecentAppHosts.Select(host => host.Id.AppHostPath));
        Assert.Equal(IdentityPath(paths[^1]), Assert.Single(controller.State.AppHosts).Id.AppHostPath);
        Assert.Equal(limit > 0, controller.State.CanClearRecent);
 
        controller.RemoveRecent(paths[0]);
        Assert.False(store.Load().ConfirmStop);
        controller.ClearRecent();
        Assert.False(store.Load().ConfirmStop);
        Assert.Empty(controller.State.RecentAppHosts);
        controller.SetPinned(paths[^2], true);
        controller.SetPinned(paths[^2], false);
        Assert.False(store.Load().ConfirmStop);
        File.Delete(paths[^1]);
        controller.PruneMissingPinnedAppHosts();
        Assert.Empty(store.Load().AppHosts);
        Assert.False(store.Load().ConfirmStop);
    }
 
    [Fact]
    public async Task DisablingHistoryDoesNotPreventStartsRetryOrDiscovery()
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("apphost.cs");
        var client = new TestAppHostClient();
        var store = new TestTraySavedStateStore(TraySavedState.Empty);
        await using var controller = new TrayController(client, store, 0);
        controller.Start();
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        controller.RequestStart(path);
        Assert.Equal(path, await client.NextStartAsync());
        Assert.Empty(controller.State.RecentAppHosts);
        Assert.True(controller.State.HasActiveAppHosts);
        Assert.False(controller.State.CanClearRecent);
        client.CompleteStart(path, new(StartOutcome.Failed, 7));
        await WaitForStateAsync(controller, state => state.Status == "Unable to start AppHost (CLI exit 7).");
        Assert.False(controller.State.HasActiveAppHosts);
 
        controller.RequestStart(path);
        Assert.Equal(path, await client.NextStartAsync());
        var starting = controller.State;
        client.CompleteStart(path, new(StartOutcome.Started, 0));
        await WaitForStateAsync(controller, state => !ReferenceEquals(starting, state));
        Assert.Throws<InvalidOperationException>(() => controller.RequestStart(path));
        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);
        Assert.Empty(store.Load().AppHosts);
        await client.PublishAndWaitAsync(controller, new([], DiscoveryState.Live));
        await WaitForStateAsync(controller, state => !state.HasActiveAppHosts);
        Assert.Empty(controller.State.RecentAppHosts);
    }
 
    [Theory]
    [InlineData(-1)]
    [InlineData(51)]
    public void ControllerRejectsOutOfRangeLimits(int limit)
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new TrayController(new TestAppHostClient(), new MemoryTraySavedStateStore(), limit));
    }
 
    private static string IdentityPath(string path) => new AppHostInfo(path, 0, null).Id.AppHostPath;
}