File: TraySavedStateTests.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;
 
namespace Aspire.Tray.Tests;
 
public class TraySavedStateTests
{
    [Fact]
    public void AppHostFixtureReturnsNormalizedPaths()
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("project/./apphost.cs");
 
        Assert.Equal(Path.GetFullPath(path), path);
        Assert.True(File.Exists(path));
    }
 
    [Fact]
    public void StateReloadPreservesPathsRecencyAndPinFlags()
    {
        using var directory = new TestTrayStateDirectory();
        var first = directory.CreateAppHost("first/apphost.cs");
        var second = directory.CreateAppHost("second/apphost.cs");
        var saved = TraySavedState.Empty.Remember(first).Remember(second).SetPinned(first, true);
        new FileTraySavedStateStore(directory.StatePath).Save(saved);
 
        var reloaded = new FileTraySavedStateStore(directory.StatePath).Load();
 
        Assert.Equal(saved.AppHosts, reloaded.AppHosts);
        using var json = JsonDocument.Parse(File.ReadAllText(directory.StatePath));
        Assert.Equal(["appHosts", "confirmStop"], json.RootElement.EnumerateObject().Select(property => property.Name));
        Assert.All(json.RootElement.GetProperty("appHosts").EnumerateArray(), host =>
            Assert.Equal(["appHostPath", "isPinned", "isRecent"], host.EnumerateObject().Select(property => property.Name)));
        Assert.Equal([directory.StatePath], Directory.GetFiles(Path.GetDirectoryName(directory.StatePath)!));
    }
 
    [Fact]
    public void RecentHistoryIsBoundedWithoutEvictingPins()
    {
        var paths = Enumerable.Range(0, 30).Select(index => Path.GetFullPath($"project-{index}/apphost.cs")).ToArray();
        var saved = TraySavedState.Empty.Remember(paths[0]).SetPinned(paths[0], true);
        foreach (var path in paths.Skip(1))
        {
            saved = saved.Remember(path);
        }
 
        Assert.Equal(paths.TakeLast(10).Reverse(), saved.AppHosts.Where(host => host.IsRecent).Select(host => host.AppHostPath));
        Assert.Equal(new SavedAppHost(paths[0], true, false), saved.AppHosts.Single(host => host.IsPinned));
        Assert.Equal(11, saved.AppHosts.Count);
 
        saved = saved.Remember(paths[20]);
        Assert.Equal(paths[20], saved.AppHosts[0].AppHostPath);
        Assert.Equal(10, saved.AppHosts.Count(host => host.IsRecent));
        Assert.Equal(11, saved.AppHosts.Count);
    }
 
    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(10)]
    [InlineData(50)]
    public void ConfiguredHistoryLimitsPreservePinsAndStopPreference(int limit)
    {
        var paths = Enumerable.Range(0, 52).Select(index => Path.GetFullPath($"project-{index}/apphost.cs")).ToArray();
        var state = (TraySavedState.Empty with { ConfirmStop = false }).SetPinned(paths[0], true);
        foreach (var path in paths)
        {
            state = state.Remember(path, limit);
        }
 
        Assert.False(state.ConfirmStop);
        Assert.Equal(paths.TakeLast(limit).Reverse(), state.AppHosts.Where(host => host.IsRecent).Select(host => host.AppHostPath));
        Assert.Equal(new SavedAppHost(paths[0], true, false), Assert.Single(state.AppHosts, host => host.IsPinned));
        Assert.Equal(limit + 1, state.AppHosts.Count);
    }
 
    [Fact]
    public void AllHistoryTransformsPreserveDisabledStopConfirmation()
    {
        var pinned = Path.GetFullPath("pinned/apphost.cs");
        var recent = Path.GetFullPath("recent/apphost.cs");
        var other = Path.GetFullPath("other/apphost.cs");
        var state = (TraySavedState.Empty with { ConfirmStop = false }).Remember(pinned).SetPinned(pinned, true).Remember(recent);
        TraySavedState[] transformed =
        [
            state.Remember(other),
            state.Trim(0),
            state.ClearRecent(),
            state.RemoveRecent(pinned),
            state.RemoveRecent(recent),
            state.SetPinned(pinned, false),
            state.SetPinned(recent, true),
            state.SetPinned(other, true),
            state.SetPinned(other, false),
            state.RemoveMissingPins(new HashSet<string>([pinned], TrayAppHostPath.Comparer))
        ];
 
        Assert.All(transformed, saved => Assert.False(saved.ConfirmStop));
        Assert.True(TraySavedState.Empty.ConfirmStop);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void StopConfirmationRoundTripsAlongsideHistory(bool confirmStop)
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("apphost.cs");
        var state = TraySavedState.Empty.Remember(path).SetPinned(path, true) with { ConfirmStop = confirmStop };
        new FileTraySavedStateStore(directory.StatePath).Save(state);
 
        var reloaded = new FileTraySavedStateStore(directory.StatePath).Load();
        Assert.Equal(confirmStop, reloaded.ConfirmStop);
        Assert.Equal(state.AppHosts, reloaded.AppHosts);
        using var json = JsonDocument.Parse(File.ReadAllText(directory.StatePath));
        Assert.Equal(confirmStop, json.RootElement.GetProperty("confirmStop").GetBoolean());
    }
 
    [Theory]
    [InlineData(20)]
    [InlineData(50)]
    public void FileStoreAcceptsOldAndMaximumHistoryCounts(int count)
    {
        using var directory = new TestTrayStateDirectory();
        var paths = Enumerable.Range(0, count).Select(index => Path.GetFullPath($"project-{index}/apphost.cs")).ToArray();
        var state = new TraySavedState(paths.Select(path => new SavedAppHost(path, false, true)).ToArray());
        new FileTraySavedStateStore(directory.StatePath).Save(state);
 
        Assert.Equal(state.AppHosts, new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
    }
 
    [Fact]
    public void FileStoreRejectsHistoryOverFiftyWithoutLosingTheExistingFile()
    {
        using var directory = new TestTrayStateDirectory();
        var state = new TraySavedState(Enumerable.Range(0, 51)
            .Select(index => new SavedAppHost(Path.GetFullPath($"project-{index}/apphost.cs"), false, true)).ToArray());
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty);
        var contents = File.ReadAllText(directory.StatePath);
 
        Assert.Throws<InvalidDataException>(() => store.Save(state));
        Assert.Equal(contents, File.ReadAllText(directory.StatePath));
    }
 
    [Fact]
    public void SavedStateUsesPlatformPathComparisonWithoutLosingPins()
    {
        using var directory = new TestTrayStateDirectory();
        var original = directory.CreateAppHost("project/apphost.cs");
        var differentlyCased = Path.Combine(Path.GetDirectoryName(original)!, "APPHOST.CS");
        var saved = TraySavedState.Empty.Remember(original).SetPinned(original, true).Remember(differentlyCased);
 
        SavedAppHost[] expected = OperatingSystem.IsWindows()
            ? [new(differentlyCased, true, true)]
            : [new(differentlyCased, false, true), new(original, true, true)];
        Assert.Equal(expected, saved.AppHosts);
 
        new FileTraySavedStateStore(directory.StatePath).Save(saved);
        Assert.Equal(expected, new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        Assert.Equal(expected.Select(host => host with { IsPinned = false }).ToArray(),
            saved.SetPinned(original, false).AppHosts);
    }
 
    [Fact]
    public void ClearAndRemoveRecentPreservePinsIncludingMissingFiles()
    {
        using var directory = new TestTrayStateDirectory();
        var missing = Path.GetFullPath("deleted/apphost.cs");
        var recent = Path.GetFullPath("other/apphost.cs");
        var saved = TraySavedState.Empty.Remember(missing).SetPinned(missing, true).Remember(recent);
 
        Assert.Equal([new SavedAppHost(missing, true, false)], saved.ClearRecent().AppHosts);
        Assert.Equal([new SavedAppHost(recent, false, true), new SavedAppHost(missing, true, false)],
            saved.RemoveRecent(missing).AppHosts);
 
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(saved.ClearRecent());
        Assert.Equal([new SavedAppHost(missing, true, false)], new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        Assert.Empty(saved.ClearRecent().SetPinned(missing, false).AppHosts);
    }
 
    [Fact]
    public void RemovingMissingPinsForgetsTheirHistoryWithoutRemovingUnpinnedRecents()
    {
        var pinned = Path.GetFullPath("pinned/apphost.cs");
        var recent = Path.GetFullPath("recent/apphost.cs");
        var retained = Path.GetFullPath("retained/apphost.cs");
        var state = TraySavedState.Empty.Remember(pinned).SetPinned(pinned, true)
            .Remember(recent).Remember(retained).SetPinned(retained, true);
        var missing = new HashSet<string>([pinned, recent], TrayAppHostPath.Comparer);
 
        Assert.Equal([new SavedAppHost(retained, true, true), new SavedAppHost(recent, false, true)],
            state.RemoveMissingPins(missing).AppHosts);
    }
 
    [Theory]
    [InlineData("{broken")]
    [InlineData("null")]
    [InlineData("{}")]
    [InlineData("{\"appHosts\":null}")]
    [InlineData("{\"appHosts\":[null]}")]
    [InlineData("{\"appHosts\":[],\"confirmStop\":null}")]
    [InlineData("{\"appHosts\":[],\"confirmStop\":\"false\"}")]
    [InlineData("{\"appHosts\":[{\"appHostPath\":\"relative/apphost.cs\",\"isPinned\":true,\"isRecent\":false}]}")]
    [InlineData("{\"appHosts\":[],\"dashboardUrl\":\"https://localhost/?token=private\"}")]
    public void CorruptStateIsNeverOverwritten(string contents)
    {
        using var directory = new TestTrayStateDirectory();
        directory.WriteState(contents);
        var store = new FileTraySavedStateStore(directory.StatePath);
 
        var exception = Record.Exception(store.Load);
        Assert.True(exception is JsonException or InvalidDataException);
        Assert.Throws<InvalidDataException>(() => store.Save(TraySavedState.Empty.Remember(Path.GetFullPath("apphost.cs"))));
        Assert.Equal(contents, File.ReadAllText(directory.StatePath));
    }
 
    [Fact]
    public void SaveDetectsExternalChangesInsteadOfReplacingThem()
    {
        using var directory = new TestTrayStateDirectory();
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty);
        directory.WriteState("externally edited, incomplete state");
 
        Assert.Throws<IOException>(() => store.Save(TraySavedState.Empty.Remember(Path.GetFullPath("apphost.cs"))));
        Assert.Equal("externally edited, incomplete state", File.ReadAllText(directory.StatePath));
    }
 
    [Fact]
    public void RepeatedWritesReplaceCompleteStateAndLeaveNoTemporaryFiles()
    {
        using var directory = new TestTrayStateDirectory();
        var path = directory.CreateAppHost("apphost.cs");
        var store = new FileTraySavedStateStore(directory.StatePath);
        store.Save(TraySavedState.Empty.Remember(path));
        store.Save(TraySavedState.Empty.Remember(path).SetPinned(path, true));
        store.Save(TraySavedState.Empty);
 
        Assert.Empty(new FileTraySavedStateStore(directory.StatePath).Load().AppHosts);
        Assert.Equal([directory.StatePath], Directory.GetFiles(Path.GetDirectoryName(directory.StatePath)!));
    }
 
    [Fact]
    public void StateFileAndDirectoryArePrivateOnUnix()
    {
        if (OperatingSystem.IsWindows())
        {
            return;
        }
        using var directory = new TestTrayStateDirectory();
        new FileTraySavedStateStore(directory.StatePath).Save(TraySavedState.Empty);
 
        Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(directory.StatePath));
        Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
            File.GetUnixFileMode(Path.GetDirectoryName(directory.StatePath)!));
    }
 
    [Fact]
    public void StatePathMustBeAbsolute()
    {
        Assert.Throws<ArgumentException>(() => new FileTraySavedStateStore("history.json"));
        Assert.Throws<ArgumentException>(() => TrayAppHostPath.Normalize("relative/apphost.cs"));
    }
 
    [Fact]
    public void SourceProbesDistinguishExistingAndDanglingSymbolicLinks()
    {
        if (OperatingSystem.IsWindows())
        {
            return;
        }
        using var directory = new TestTrayStateDirectory();
        var target = directory.CreateAppHost("target/apphost.cs");
        var link = Path.Combine(Path.GetDirectoryName(target)!, "linked.cs");
        File.CreateSymbolicLink(link, target);
 
        Assert.False(TrayAppHostPath.IsMissing(link));
        Assert.Equal(link, TrayAppHostPath.RequireExistingFile(link));
        File.Delete(target);
        Assert.True(TrayAppHostPath.IsMissing(link));
        Assert.Throws<FileNotFoundException>(() => TrayAppHostPath.RequireExistingFile(link));
    }
}