File: CliProtocolTests.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.Shared;
 
namespace Aspire.Tray.Tests;
 
public class CliProtocolTests
{
    [Fact]
    public void EmptySnapshotIsExplicitlyLive()
    {
        var snapshot = Parse("""{"version":1,"type":"snapshot","appHosts":[]}""");
        Assert.Equal(DiscoveryState.Live, snapshot.Discovery);
        Assert.Empty(snapshot.AppHosts);
    }
 
    [Fact]
    public void CompleteSnapshotReplacesPriorInstancesAndDashboardData()
    {
        var first = Parse(Snapshot(Host(42), Host(43)));
        var second = Parse(Snapshot(Host(43) with { DashboardUrl = "https://localhost:5678/" }));
        Assert.Equal([42, 43], first.AppHosts.Select(host => host.AppHostPid));
        Assert.Equal(43, Assert.Single(second.AppHosts).AppHostPid);
        Assert.Equal("https://localhost:5678/", second.AppHosts[0].DashboardUri?.AbsoluteUri);
    }
 
    [Fact]
    public void LifetimeIsPartOfIdentityButDashboardUrlIsNot()
    {
        var original = Parse(Snapshot(Host(42))).AppHosts[0];
        var updated = original with { DashboardUrl = "https://localhost:5678/" };
        var replacement = original with { ProcessStartTimeUnixMilliseconds = 2000 };
        Assert.Equal(original.Id, updated.Id);
        Assert.NotEqual(original.Id, replacement.Id);
    }
 
    [Theory]
    [InlineData(null, "Unknown")]
    [InlineData("healthy", "Healthy")]
    [InlineData("warning", "Warning")]
    [InlineData("unhealthy", "Unhealthy")]
    [InlineData("future-health", "Unknown")]
    public void OptionalHealthMapsWithoutChangingProcessIdentity(string? health, string expected)
    {
        var original = Assert.Single(Parse(Snapshot(Host(42))).AppHosts);
        var updated = Assert.Single(Parse(Snapshot(Host(42) with { Health = health })).AppHosts);
 
        Assert.Equal(expected, updated.Health.ToString());
        Assert.Equal(original.Id, updated.Id);
        Assert.Equal(expected == "Unknown", original == updated);
    }
 
    [Fact]
    public void OlderSnapshotWithoutHealthIsUnknown()
    {
        // Use an older producer's frame, not today's serializer with a default property.
        var path = JsonSerializer.Serialize(Path.GetFullPath("sample/apphost.cs"));
        var host = Assert.Single(Parse(
            $$"""{"version":1,"type":"snapshot","appHosts":[{"appHostPath":{{path}},"appHostPid":42,"processStartTimeUnixMilliseconds":1000}]}""").AppHosts);
 
        Assert.Equal(AppHostHealth.Unknown, host.Health);
        Assert.Equal(42, host.AppHostPid);
        Assert.Equal(1000, host.ProcessStartTimeUnixMilliseconds);
    }
 
    [Fact]
    public void PathIdentityUsesPlatformCaseSemantics()
    {
        var host = Parse(Snapshot(Host(42))).AppHosts[0];
        var otherCase = host with { AppHostPath = host.AppHostPath.ToUpperInvariant() };
        Assert.Equal(OperatingSystem.IsWindows(), host.Id == otherCase.Id);
    }
 
    [Theory]
    [InlineData("{}")]
    [InlineData("{broken")]
    [InlineData("""{"type":"snapshot","appHosts":[]}""")]
    [InlineData("""{"version":1,"appHosts":[]}""")]
    public void MalformedOrMissingRequiredFieldsAreRejected(string json)
    {
        Assert.Throws<JsonException>(() => CliProtocol.ReadWatchMessage(json));
    }
 
    [Theory]
    [InlineData("null")]
    [InlineData("""{"version":2,"type":"snapshot","appHosts":[]}""")]
    [InlineData("""{"version":1,"type":"snapshot"}""")]
    [InlineData("""{"version":1,"type":"running","appHosts":[]}""")]
    [InlineData("""{"version":1,"type":"heartbeat","appHosts":[]}""")]
    [InlineData("""{"version":1,"type":"error"}""")]
    public void UnsupportedOrConflictingFramesAreRejected(string json)
    {
        Assert.Throws<CliProtocolException>(() => CliProtocol.ReadWatchMessage(json));
    }
 
    [Fact]
    public void UnknownAdditiveFieldsDoNotBreakTheProtocol()
    {
        var snapshot = Parse("""{"version":1,"type":"snapshot","appHosts":[],"futureField":true}""");
        Assert.Empty(snapshot.AppHosts);
        Assert.Equal(DiscoveryState.Live, snapshot.Discovery);
    }
 
    [Fact]
    public void FailureIsNotAnEmptySnapshot()
    {
        var message = CliProtocol.ReadWatchMessage("""{"version":1,"type":"error","errorCode":"discovery_failed"}""");
        Assert.Equal("error", message.Type);
        Assert.Null(message.AppHosts);
        Assert.Throws<CliProtocolException>(() => CliProtocol.ReadSnapshot(message));
    }
 
    [Fact]
    public void InvalidIdentityAndDuplicateProcessesAreRejected()
    {
        var invalid = new[]
        {
            Host(0), Host(-1), Host(42) with { AppHostPath = "relative" },
            Host(42) with { ProcessStartTimeUnixMilliseconds = 0 },
            Host(42) with { AppHostPath = Path.GetFullPath("apphost.cs") + "\0" }
        };
        foreach (var host in invalid)
        {
            Assert.Throws<CliProtocolException>(() => Parse(Snapshot(host)));
        }
        Assert.Throws<CliProtocolException>(() => Parse(Snapshot(Host(42), Host(42))));
        Assert.Throws<CliProtocolException>(() => Parse(Snapshot(Host(42), Host(42) with { AppHostPath = Path.GetFullPath("other.cs") })));
    }
 
    [Fact]
    public void UnavailableLifetimeIsDisplayableWithoutInventingAnIdentity()
    {
        var host = Assert.Single(Parse(Snapshot(Host(42) with { ProcessStartTimeUnixMilliseconds = null })).AppHosts);
        Assert.Null(host.Id.ProcessStartTimeUnixMilliseconds);
    }
 
    [Fact]
    public void HostLimitIsInclusiveAndNeverTruncatesAList()
    {
        var hosts = Enumerable.Range(1, TrayCliProtocol.MaximumAppHosts).Select(Host).ToArray();
        Assert.Equal(TrayCliProtocol.MaximumAppHosts, Parse(Snapshot(hosts)).AppHosts.Count);
        Assert.Throws<CliProtocolException>(() => Parse(Snapshot([.. hosts, Host(hosts.Length + 1)])));
    }
 
    [Theory]
    [InlineData("""{"version":2,"outcome":"stopped","exitCode":0}""")]
    [InlineData("""{"version":1,"outcome":"stopped","exitCode":7}""")]
    [InlineData("""{"version":1,"outcome":"not_found","exitCode":0}""")]
    [InlineData("""{"version":1,"outcome":"future_outcome","exitCode":7}""")]
    public void StopResultMustHaveAKnownVersionOutcomeAndConsistentSuccess(string json)
    {
        Assert.Throws<CliProtocolException>(() => CliProtocol.ReadStopMessage(json));
    }
 
    [Fact]
    public async Task NdjsonHandlesCrLfEmptyLinesAndUnterminatedLastLine()
    {
        Assert.Equal(["{}", """{"type":"heartbeat"}""", "{}"], await LinesAsync("{}\r\n\n{\"type\":\"heartbeat\"}\n{}"));
    }
 
    [Fact]
    public async Task MessageLimitIsInclusive()
    {
        var value = new string('x', TrayCliProtocol.MaximumMessageLength);
        Assert.Equal([value], await LinesAsync(value + "\n"));
        Assert.Equal([value], await LinesAsync(value + "\r\n"));
        await Assert.ThrowsAsync<CliProtocolException>(() => LinesAsync(value + "x\n"));
        await Assert.ThrowsAsync<CliProtocolException>(() => LinesAsync(value + "\rx\n"));
    }
 
    internal static TrayAppHost Host(int pid) => new()
    {
        AppHostPath = Path.GetFullPath("sample/apphost.cs"),
        AppHostPid = pid,
        ProcessStartTimeUnixMilliseconds = 1000,
        DashboardUrl = "https://localhost:1234/"
    };
 
    internal static string Snapshot(params TrayAppHost[] hosts)
        => JsonSerializer.Serialize(new TrayWatchMessage
        {
            Version = TrayCliProtocol.Version,
            Type = "snapshot",
            AppHosts = hosts
        }, TrayCliJsonContext.Default.TrayWatchMessage);
 
    private static AppHostSnapshot Parse(string json) => CliProtocol.ReadSnapshot(CliProtocol.ReadWatchMessage(json));
 
    private static async Task<List<string>> LinesAsync(string text)
    {
        using var reader = new StringReader(text);
        var lines = new List<string>();
        await foreach (var line in CliProtocol.ReadLinesAsync(reader, TestContext.Current.CancellationToken).ConfigureAwait(true))
        {
            lines.Add(line);
        }
        return lines;
    }
}