File: WithUrlsTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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.Collections.Immutable;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
 
namespace Aspire.Hosting.Tests;
 
public class WithUrlsTests
{
    [Fact]
    public void WithUrlsAddsAnnotationForAsyncCallback()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        Func<ResourceUrlsCallbackContext, Task> callback = c => Task.CompletedTask;
 
        var projectA = builder.AddProject<ProjectA>("projecta")
                              .WithUrls(callback);
 
        var urlsCallback = projectA.Resource.Annotations.OfType<ResourceUrlsCallbackAnnotation>()
            .Where(a => a.Callback == callback).FirstOrDefault();
 
        Assert.NotNull(urlsCallback);
    }
 
    [Fact]
    public void WithUrlsAddsAnnotationForSyncCallback()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta");
 
        Assert.Empty(projectA.Resource.Annotations.OfType<ResourceUrlsCallbackAnnotation>());
 
        projectA.WithUrls(c => { });
 
        Assert.NotEmpty(projectA.Resource.Annotations.OfType<ResourceUrlsCallbackAnnotation>());
    }
 
    [Fact]
    public async Task WithUrlsCallsCallbackAfterBeforeResourceStartedEvent()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var called = false;
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithUrls(c => called = true);
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>((e, ct) =>
        {
            // Should not be called at this point
            Assert.False(called);
            return Task.CompletedTask;
        });
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            // Should be called by the time resource is started
            Assert.True(called);
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
 
        await tcs.Task;
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlsProvidesLoggerInstanceOnCallbackContextAllocated()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        ILogger logger = NullLogger.Instance;
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithUrls(c => logger = c.Logger);
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
 
        await tcs.Task;
 
        Assert.NotNull(logger);
        Assert.True(logger is not NullLogger);
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlsAddsUrlAnnotations()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithUrls(c => c.Urls.Add(new() { Url = "https://example.com", DisplayText = "Example" }));
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url == "https://example.com" && u.DisplayText == "Example");
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlAddsUrlAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithUrl("https://example.com", "Example");
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url == "https://example.com" && u.DisplayText == "Example");
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlInterpolatedStringAddsUrlAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpsEndpoint();
        projectA.WithUrl($"{projectA.Resource.GetEndpoint("https")}/test", "Example");
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        var endpointUrl = urls.First(u => u.Endpoint is not null);
        Assert.Collection(urls,
            u => Assert.True(u.Url == endpointUrl.Url && u.DisplayText is null),
            u => Assert.True(u.Url.EndsWith("/test") && u.DisplayText == "Example")
        );
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task EndpointsResultInUrls()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test");
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url.StartsWith("http://localhost") && u.Endpoint?.EndpointName == "test");
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task ProjectLaunchProfileRelativeLaunchUrlIsAddedToEndpointUrl()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectB>("projectb");
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url.EndsWith("/sub-path") && u.Endpoint?.EndpointName == "http");
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task ProjectLaunchProfileAbsoluteLaunchUrlIsUsedAsEndpointUrl()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectB>("projectb", launchProfileName: "custom");
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url == "http://custom.localhost:23456/home" && u.Endpoint?.EndpointName == "http");
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlForEndpointUpdatesUrlForEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("test", u =>
            {
                u.Url = "https://example.com";
                u.DisplayText = "Link Text";
                u.DisplayOrder = 1000;
            });
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u =>
            u.Url == "https://example.com"
            && u.DisplayText == "Link Text"
            && u.Endpoint?.EndpointName == "test"
            && u.DisplayOrder == 1000);
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task EndpointUrlsAreInitiallyInactive()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var servicea = builder.AddProject<Projects.ServiceA>("servicea")
            .WithUrlForEndpoint("http", u => u.Url = "https://example.com");
 
        var httpEndpoint = servicea.Resource.GetEndpoint("http");
 
        var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
        ImmutableArray<UrlSnapshot> initialUrlSnapshot = default;
        var cts = new CancellationTokenSource();
        var watchTask = Task.Run(async () =>
        {
            await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token))
            {
                if (notification.Snapshot.Urls.Length > 0 && initialUrlSnapshot == default)
                {
                    initialUrlSnapshot = notification.Snapshot.Urls;
                    break;
                }
            }
        });
 
        await app.StartAsync();
 
        await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        cts.Cancel();
 
        await app.StopAsync();
 
        Assert.Single(initialUrlSnapshot, s => s.Name == httpEndpoint.EndpointName && s.IsInactive && s.Url == "https://example.com");
    }
 
    [Fact]
    public async Task MultipleUrlsForSingleEndpointAreIncludedInUrlSnapshot()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var servicea = builder.AddProject<Projects.ServiceA>("servicea");
        var httpEndpoint = servicea.Resource.GetEndpoint("http");
        servicea.WithUrl($"{httpEndpoint}/one", "Example 1");
        servicea.WithUrl($"{httpEndpoint}/two", "Example 2");
 
        var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
        ImmutableArray<UrlSnapshot> initialUrlSnapshot = default;
        var cts = new CancellationTokenSource();
        var watchTask = Task.Run(async () =>
        {
            await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token))
            {
                if (notification.Snapshot.Urls.Length > 0 && initialUrlSnapshot == default)
                {
                    initialUrlSnapshot = notification.Snapshot.Urls;
                    break;
                }
            }
        });
 
        await app.StartAsync();
 
        await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        cts.Cancel();
 
        await app.StopAsync();
 
        Assert.Collection(initialUrlSnapshot,
            s => Assert.True(s.Name == httpEndpoint.EndpointName && s.DisplayProperties.DisplayName == ""), // <-- this is the default URL added for the endpoint
            s => Assert.True(s.Name == httpEndpoint.EndpointName && s.Url.EndsWith("/one") && s.DisplayProperties.DisplayName == "Example 1"),
            s => Assert.True(s.Name == httpEndpoint.EndpointName && s.Url.EndsWith("/two") && s.DisplayProperties.DisplayName == "Example 2")
        );
    }
 
    [Fact]
    public async Task NonEndpointUrlsAreInactiveUntilResourceRunning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        builder.AddProject<Projects.ServiceA>("servicea")
            .WithUrl("https://example.com");
 
        var app = await builder.BuildAsync();
 
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
        ImmutableArray<UrlSnapshot> initialUrlSnapshot = default;
        ImmutableArray<UrlSnapshot> urlSnapshotAfterRunning = default;
        var cts = new CancellationTokenSource();
        var watchTask = Task.Run(async () =>
        {
            await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token))
            {
                if (notification.Snapshot.Urls.Length > 0 && initialUrlSnapshot == default)
                {
                    initialUrlSnapshot = notification.Snapshot.Urls;
                    continue;
                }
 
                if (string.Equals(notification.Snapshot.State?.Text, KnownResourceStates.Running))
                {
                    if (notification.Snapshot.Urls.Length > 0 && urlSnapshotAfterRunning == default)
                    {
                        urlSnapshotAfterRunning = notification.Snapshot.Urls;
                        break;
                    }
                }
            }
        });
 
        await app.StartAsync();
 
        await rns.WaitForResourceAsync("servicea", KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        cts.Cancel();
 
        await app.StopAsync();
 
        Assert.All(initialUrlSnapshot, s => Assert.True(s.IsInactive));
        Assert.Single(urlSnapshotAfterRunning, s => !s.IsInactive && s.Url == "https://example.com");
    }
 
    [Fact]
    public async Task UrlsAreMarkedAsInternalDependingOnDisplayLocation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        builder.AddProject<Projects.ServiceA>("servicea")
            .WithUrls(c =>
            {
                c.Urls.Add(new() { Url = "http://example.com/", DisplayLocation = UrlDisplayLocation.SummaryAndDetails });
                c.Urls.Add(new() { Url = "http://example.com/internal", DisplayLocation = UrlDisplayLocation.DetailsOnly });
                c.Urls.Add(new() { Url = "http://example.com/out-of-range", DisplayLocation = (UrlDisplayLocation)100 });
            });
 
        var app = await builder.BuildAsync();
 
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
        ImmutableArray<UrlSnapshot> urlSnapshot = default;
        var cts = new CancellationTokenSource();
        var watchTask = Task.Run(async () =>
        {
            await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token))
            {
                if (string.Equals(notification.Snapshot.State?.Text, KnownResourceStates.Running))
                {
                    if (notification.Snapshot.Urls.Length > 1 && urlSnapshot == default)
                    {
                        urlSnapshot = notification.Snapshot.Urls;
                        break;
                    }
                }
            }
        });
 
        await app.StartAsync();
 
        await rns.WaitForResourceAsync("servicea", KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        cts.Cancel();
 
        await app.StopAsync();
 
        Assert.Collection(urlSnapshot,
            url => { Assert.Equal("http", url.Name); Assert.False(url.IsInternal); },
            url => { Assert.Equal("http://example.com/", url.Url); Assert.False(url.IsInternal); },
            url => { Assert.Equal("http://example.com/internal", url.Url); Assert.True(url.IsInternal); },
            url => { Assert.Equal("http://example.com/out-of-range", url.Url); Assert.False(url.IsInternal); }
        );
    }
 
    [Fact]
    public async Task WithUrlForEndpointUpdateDoesNotThrowOrCallCallbackIfEndpointNotFound()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var called = false;
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("non-existant", u =>
            {
                called = true;
            });
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        Assert.False(called);
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlForEndpointAddDoesNotThrowOrCallCallbackIfEndpointNotFound()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var called = false;
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("non-existant", ep =>
            {
                called = true;
                return new() { Url = "https://example.com" };
            });
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        Assert.False(called);
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlForEndpointUpdateTurnsRelativeUrlIntoAbsoluteUrl()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("test", url =>
            {
                url.Url = "/sub-path";
            });
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var endpointUrl = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>().FirstOrDefault(u => u.Endpoint?.EndpointName == "test");
 
        Assert.NotNull(endpointUrl);
        Assert.True(endpointUrl.Url.StartsWith("http://localhost") && endpointUrl.Url.EndsWith("/sub-path"));
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlForEndpointAddTurnsRelativeUrlIntoAbsoluteUrl()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("test", ep =>
            {
                return new() { Url = "/sub-path" };
            });
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var endpointUrl = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>().FirstOrDefault(u => u.Endpoint?.EndpointName == "test" && u.Url.EndsWith("/sub-path"));
 
        Assert.NotNull(endpointUrl);
        Assert.True(endpointUrl.Url.StartsWith("http://localhost") && endpointUrl.Url.EndsWith("/sub-path"));
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task WithUrlsTurnsRelativeEndpointUrlsIntoAbsoluteUrls()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrls(c =>
            {
                c.Urls.Add(new() { Endpoint = c.GetEndpoint("test"), Url = "/sub-path" });
            });
 
        var tcs = new TaskCompletionSource();
        builder.Eventing.Subscribe<BeforeResourceStartedEvent>(projectA.Resource, (e, ct) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task;
 
        var endpointUrl = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>().FirstOrDefault(u => u.Endpoint?.EndpointName == "test" && u.Url.EndsWith("/sub-path"));
 
        Assert.NotNull(endpointUrl);
        Assert.True(endpointUrl.Url.StartsWith("http://localhost") && endpointUrl.Url.EndsWith("/sub-path"));
 
        await app.StopAsync();
    }
 
    private sealed class ProjectA : IProjectMetadata
    {
        public string ProjectPath => "projectA";
 
        public LaunchSettings LaunchSettings { get; } = new();
    }
 
    private sealed class ProjectB : IProjectMetadata
    {
        public string ProjectPath => "project";
 
        public LaunchSettings LaunchSettings { get; } = new()
        {
            Profiles = new Dictionary<string, LaunchProfile>
            {
                ["http"] = new()
                {
                    CommandName = "Project",
                    ApplicationUrl = "http://localhost:23456",
                    LaunchUrl = "/sub-path"
                },
                ["custom"] = new()
                {
                    CommandName = "Project",
                    ApplicationUrl = "http://localhost:23456",
                    LaunchUrl = "http://custom.localhost:23456/home"
                }
            }
        };
    }
}