// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Hosting.Testing;
using Aspire.Hosting.Tests.Dcp;
using Aspire.Hosting.Utils;
using Aspire.Shared.TerminalHost;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API.
namespace Aspire.Hosting.Tests.Terminals;
/// <summary>
/// Guards the projection of terminal-enabled resources into the same shape as AppHost-owned terminals.
/// Nothing here connects to a terminal host: the catalog reads only the application model, which is what
/// lets a listing be produced without a running workload.
/// </summary>
[Trait("Partition", "2")]
public class ResourceTerminalCatalogTests : IAsyncLifetime
{
// Leave room for the generated host socket name under macOS's 104-byte Unix socket path limit.
private readonly string _terminalRoot = Directory.CreateTempSubdirectory().FullName;
private string _terminalDirectory => Path.Combine(_terminalRoot, ".aspire", "trmnl");
[Fact]
public void BuildIdRoundTripsThroughIsResourceTerminalId()
{
var id = ResourceTerminalCatalog.BuildId("shellbox", 2);
Assert.Equal("resource:shellbox:2", id);
Assert.True(ResourceTerminalCatalog.IsResourceTerminalId(id));
}
[Fact]
public void AppHostTerminalIdIsNotMistakenForAResourceTerminal()
{
// AppHost terminal ids are opaque, so the prefix is the only thing separating the two id spaces.
var service = TestTerminalService.Create();
var terminal = service.CreateTerminal(new TerminalLaunchOptions
{
Title = "Shell",
Executable = "bash",
});
Assert.False(ResourceTerminalCatalog.IsResourceTerminalId(terminal.Id));
}
[Fact]
public async Task ListReturnsNothingBeforeTerminalHostsAreMaterialized()
{
// TerminalAnnotation.TerminalHosts stays empty until BeforeStartEvent, so a catalog built against a
// model that hasn't started yet must report an empty list rather than throwing or inventing entries.
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
await using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
await using var catalog = new ResourceTerminalCatalog(model, NullLogger.Instance);
Assert.Empty(catalog.List());
}
[Fact]
public async Task ListProjectsOneEntryPerReplica()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithAnnotation(new ReplicaAnnotation(3)).WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
var entries = catalog.List().OrderBy(e => e.ReplicaIndex).ToList();
Assert.Collection(entries,
e => Assert.Equal("resource:myapp:0", e.Id),
e => Assert.Equal("resource:myapp:1", e.Id),
e => Assert.Equal("resource:myapp:2", e.Id));
}
[Fact]
public async Task SingleReplicaTitleIsNotQualifiedByReplicaIndex()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
var entry = Assert.Single(catalog.List());
Assert.Equal("myapp", entry.Title);
}
[Fact]
public async Task EveryReplicaIsQualifiedWhenResourceHasMoreThanOne()
{
// Replica 0 has to be qualified too. Deciding on the index alone would leave the first replica of a
// scaled-out resource labelled as if it were the only one.
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithAnnotation(new ReplicaAnnotation(2)).WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
var titles = catalog.List().OrderBy(e => e.ReplicaIndex).Select(e => e.Title).ToList();
Assert.Equal(["myapp (replica 0)", "myapp (replica 1)"], titles);
}
[Fact]
public async Task TryGetTerminalRejectsAnAppHostTerminalId()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
Assert.False(catalog.TryGetTerminal("not-a-resource-terminal", out var terminal));
Assert.Null(terminal);
}
[Fact]
public async Task TryGetTerminalRejectsAnUnknownReplica()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
Assert.False(catalog.TryGetTerminal(ResourceTerminalCatalog.BuildId("myapp", 7), out var terminal));
Assert.Null(terminal);
}
[Fact]
public async Task TryGetTerminalReturnsTheSameHandleForRepeatedLookups()
{
// Handles are cached so that repeated automation calls share one connection to the replica rather
// than adding a peer to its terminal host per call.
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
var id = ResourceTerminalCatalog.BuildId("myapp", 0);
Assert.True(catalog.TryGetTerminal(id, out var first));
Assert.True(catalog.TryGetTerminal(id, out var second));
Assert.Same(first, second);
}
[Fact]
public async Task TryGetTerminalReplacesADisposedHandle()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
var id = ResourceTerminalCatalog.BuildId("myapp", 0);
Assert.True(catalog.TryGetTerminal(id, out var first));
await first!.DisposeAsync();
Assert.True(catalog.TryGetTerminal(id, out var replacement));
Assert.NotSame(first, replacement);
Assert.Equal(id, replacement!.Id);
Assert.False(Assert.IsType<ResourceAspireTerminal>(replacement.Backend).IsDisposed);
Assert.True(catalog.TryGetTerminal(id, out var repeated));
Assert.Same(replacement, repeated);
}
[Fact]
public async Task ConcurrentLookupsShareTheReplacementHandle()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
var id = ResourceTerminalCatalog.BuildId("myapp", 0);
Assert.True(catalog.TryGetTerminal(id, out var first));
await first!.DisposeAsync();
var handles = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() =>
{
Assert.True(catalog.TryGetTerminal(id, out var handle));
return handle;
})));
Assert.NotSame(first, handles[0]);
Assert.All(handles, handle => Assert.Same(handles[0], handle));
}
[Fact]
public async Task CatalogDisposalWaitsForAReplacedHandleToFinishDisconnecting()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
var logger = new GatedLogger<ResourceAspireTerminal>("Connecting AppHost automation");
await using var catalog = await CreateCatalogAsync(builder, logger);
var id = ResourceTerminalCatalog.BuildId("myapp", 0);
Assert.True(catalog.TryGetTerminal(id, out var first));
var automation = first!.SendTextAsync("not-delivered");
try
{
await logger.Blocked.DefaultTimeout();
var firstDisposal = first.DisposeAsync().AsTask();
Assert.True(catalog.TryGetTerminal(id, out var replacement));
Assert.NotSame(first, replacement);
await replacement!.DisposeAsync();
// The current handle is already disposed. Only the replaced peer can keep shutdown pending.
var catalogDisposal = catalog.DisposeAsync().AsTask();
Assert.False(catalogDisposal.IsCompleted);
logger.Release();
await catalogDisposal.DefaultTimeout();
Assert.True(firstDisposal.IsCompleted);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => automation).DefaultTimeout();
}
finally
{
logger.Release();
await first.DisposeAsync();
}
}
[Fact]
public async Task ResourceTerminalReportsResourceOwnership()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
await using var catalog = await CreateCatalogAsync(builder);
Assert.True(catalog.TryGetTerminal(ResourceTerminalCatalog.BuildId("myapp", 0), out var terminal));
Assert.Equal(TerminalOwner.Resource, terminal!.Owner);
Assert.Equal(TerminalPlacement.ResourceView, terminal.Placement);
Assert.Equal("myapp", terminal.Title);
}
[Fact]
public async Task TryGetTerminalReturnsFalseAfterDisposal()
{
using var builder = CreateBuilder();
builder.AddExecutable("myapp", "myapp", ".").WithTerminal();
var catalog = await CreateCatalogAsync(builder);
await catalog.DisposeAsync();
Assert.False(catalog.TryGetTerminal(ResourceTerminalCatalog.BuildId("myapp", 0), out var terminal));
Assert.Null(terminal);
}
/// <summary>
/// Builds the application and publishes <see cref="BeforeStartEvent"/>, which is the seam where
/// <c>WithTerminal()</c> materializes the per-replica terminal hosts the catalog reads.
/// </summary>
private static Task<ResourceTerminalCatalog> CreateCatalogAsync(IDistributedApplicationTestingBuilder builder)
=> CreateCatalogAsync(builder, NullLogger.Instance);
private static async Task<ResourceTerminalCatalog> CreateCatalogAsync(IDistributedApplicationTestingBuilder builder, ILogger logger)
{
await using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model));
return new ResourceTerminalCatalog(model, logger);
}
private IDistributedApplicationTestingBuilder CreateBuilder()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
builder.Configuration[TerminalHostPaths.DirectoryOverrideConfigName] = _terminalDirectory;
return builder;
}
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync()
{
try
{
Directory.Delete(_terminalRoot, recursive: true);
}
catch (DirectoryNotFoundException)
{
}
return ValueTask.CompletedTask;
}
}