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 Aspire.Hosting.Dashboard;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Tests;
 
public class WithUrlsTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public void WithUrlsAddsAnnotationForAsyncCallback()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        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(testOutputHelper);
 
        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(testOutputHelper);
 
        var called = false;
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        builder.AddProject<ProjectA>("projecta")
            .WithUrls(c => called = true)
            .OnResourceEndpointsAllocated((_, _, _) =>
            {
                // Should not be called at this point
                Assert.False(called);
                return Task.CompletedTask;
            })
            .OnBeforeResourceStarted((_, _, _) =>
            {
                // Should be called by the time resource is started
                Assert.True(called);
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
 
        await tcs.Task.DefaultTimeout();
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlsProvidesLoggerInstanceOnCallbackContextAllocated()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        ILogger logger = NullLogger.Instance;
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithUrls(c => logger = c.Logger)
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
 
        await tcs.Task.DefaultTimeout();
 
        Assert.NotNull(logger);
        Assert.True(logger is not NullLogger);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlsProvidesServiceProviderInstanceOnCallbackContextAllocated()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource<IServiceProvider>(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithUrls(c =>
            {
                try
                {
                    tcs.TrySetResult(c.ExecutionContext.ServiceProvider);
                }
                catch (InvalidOperationException ex)
                {
                    tcs.TrySetException(ex);
                }
            });
 
        await using var app = await builder.BuildAsync();
 
        await app.StartAsync();
 
        Assert.NotNull(await tcs.Task.DefaultTimeout());
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlsAddsUrlAnnotations()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithUrls(c => c.Urls.Add(new() { Url = "https://example.com", DisplayText = "Example" }))
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url == "https://example.com" && u.DisplayText == "Example");
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlAddsUrlAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithUrl("https://example.com", "Example")
            .OnBeforeResourceStarted((_, _, _) =>
        {
            tcs.SetResult();
            return Task.CompletedTask;
        });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url == "https://example.com" && u.DisplayText == "Example");
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlInterpolatedStringAddsUrlAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpsEndpoint();
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        projectA.WithUrl($"{projectA.Resource.GetEndpoint("https")}/test", "Example")
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        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().DefaultTimeout();
    }
 
    [Fact]
    public async Task EndpointsResultInUrls()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url.StartsWith("http://localhost") && u.Endpoint?.EndpointName == "test");
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Theory]
    [InlineData("myapp.dev.localhost", "-myapp.dev.localhost")]
    [InlineData("myapp-apphost.dev.localhost", "-myapp.dev.localhost")]
    [InlineData("myapp_apphost.dev.localhost", "-myapp.dev.localhost")]
    [InlineData("myapp.apphost.dev.localhost", "-myapp.dev.localhost")]
    public async Task EndpointsGetDevLocalhostUrlsWhenDashboardHasDevLocalhostUrl(string dashboardHost, string expectedHostSuffix)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        builder.Services.Configure<DashboardOptions>(options =>
        {
            options.DashboardUrl = $"http://{dashboardHost}:12345";
        });
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectB = builder.AddProject<ProjectB>("projectb")
            .WithEndpoint(scheme: "tcp")
            .WithUrlForEndpoint("http", u => u.DisplayText = "Custom Display Text")
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        var urls = projectB.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Collection(urls,
            u =>
            {
                Assert.StartsWith($"http://{projectB.Resource.Name.ToLowerInvariant()}{expectedHostSuffix}", u.Url);
                Assert.EndsWith("/sub-path", u.Url);
                Assert.Equal("http", u.Endpoint?.EndpointName);
                Assert.Equal(UrlDisplayLocation.SummaryAndDetails, u.DisplayLocation);
                Assert.Equal("Custom Display Text", u.DisplayText);
            },
            u =>
            {
                Assert.StartsWith("http://localhost", u.Url);
                Assert.Equal("http", u.Endpoint?.EndpointName);
                Assert.Equal(UrlDisplayLocation.DetailsOnly, u.DisplayLocation);
            },
            u =>
            {
                Assert.StartsWith("tcp://localhost", u.Url);
                Assert.Equal("tcp", u.Endpoint?.EndpointName);
            }
        );
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task ProjectLaunchProfileRelativeLaunchUrlIsAddedToEndpointUrl()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectB>("projectb")
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        var urls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        Assert.Single(urls, u => u.Url.EndsWith("/sub-path") && u.Endpoint?.EndpointName == "http");
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task ProjectLaunchProfileAbsoluteLaunchUrlIsUsedAsEndpointUrl()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectB>("projectb", launchProfileName: "custom")
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        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().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlForEndpointUpdatesUrlForEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("test", u =>
            {
                u.Url = "https://example.com";
                u.DisplayText = "Link Text";
                u.DisplayOrder = 1000;
            })
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        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().DefaultTimeout();
    }
 
    [Fact]
    public async Task EndpointUrlsAreInitiallyInactive()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var servicea = builder.AddProject<Projects.ServiceA>("servicea")
            .WithUrlForEndpoint("http", u => u.Url = "https://example.com");
 
        var httpEndpoint = servicea.Resource.GetEndpoint("http");
 
        await using var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        await app.StartAsync();
 
        // Wait for the resource to have URLs allocated (before it starts running)
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
        var resourceEvent = await rns.WaitForResourceAsync(
            servicea.Resource.Name,
            e => e.Snapshot.Urls.Length > 0,
            cts.Token);
 
        await app.StopAsync().DefaultTimeout();
 
        Assert.Single(resourceEvent.Snapshot.Urls, s => s.Name == httpEndpoint.EndpointName && s.IsInactive && s.Url == "https://example.com");
    }
 
    [Fact]
    public async Task MultipleUrlsForSingleEndpointAreIncludedInUrlSnapshot()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        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");
 
        await using var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        await app.StartAsync();
 
        // Wait for URLs to be populated
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
        var resourceEvent = await rns.WaitForResourceAsync(
            servicea.Resource.Name,
            e => e.Snapshot.Urls.Length > 0,
            cts.Token);
 
        await app.StopAsync().DefaultTimeout();
 
        Assert.Collection(resourceEvent.Snapshot.Urls,
            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 ExpectedNumberOfUrlsForReplicatedResource()
    {
        // This test creates a single project resource with a custom URL and
        // a replica count of 3. It then checks that the number of URLs
        // generated isn't impacted by the number of replicas.
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var servicea = builder.AddProject<Projects.ServiceA>("servicea")
            .WithUrl("https://example.com/project")
            .WithReplicas(3);
 
        await using var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        await app.StartAsync();
 
        // Wait for the resource to be running
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
        var resourceEvent = await rns.WaitForResourceAsync(
            servicea.Resource.Name,
            e => e.Snapshot.State == KnownResourceStates.Running,
            cts.Token);
 
        await app.StopAsync().DefaultTimeout();
 
        Assert.Equal(2, resourceEvent.Snapshot.Urls.Length);
        Assert.Collection(resourceEvent.Snapshot.Urls,
            url => Assert.StartsWith("http://localhost:", url.Url), // The default project URL
            url => Assert.Equal("https://example.com/project", url.Url) // Static URL
        );
    }
 
    [Fact]
    public async Task ProjectResourceUrlsTransitionThroughExpectedLifecycleStates()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var servicea = builder.AddProject<Projects.ServiceA>("servicea")
            .WithUrl("https://example.com/project");
 
        await using var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration);
 
        var urlSnapshots = new List<UrlSnapshot[]>();
 
        static string FormatUrls(IEnumerable<UrlSnapshot> urls) =>
            string.Join(", ", urls.Select(u => $"[{u.Name ?? "null"}] {u.Url} (inactive={u.IsInactive})"));
 
        var watchTask = Task.Run(async () =>
        {
            await foreach (var notification in rns.WatchAsync(cts.Token))
            {
                if (notification.Resource == servicea.Resource && notification.Snapshot.Urls.Length > 0)
                {
                    urlSnapshots.Add(notification.Snapshot.Urls.ToArray());
                    testOutputHelper.WriteLine($"Captured snapshot #{urlSnapshots.Count}: State={notification.Snapshot.State}, URLs: {FormatUrls(notification.Snapshot.Urls)}");
 
                    // Stop when running and all URLs are active
                    if (notification.Snapshot.State == KnownResourceStates.Running &&
                        notification.Snapshot.Urls.All(u => !u.IsInactive))
                    {
                        break;
                    }
                }
            }
        });
 
        await app.StartAsync();
        await rns.WaitForResourceAsync(servicea.Resource.Name, KnownResourceStates.Running, cts.Token);
        await watchTask;
        await app.StopAsync().DefaultTimeout();
 
        // Log all captured snapshots for diagnostics
        testOutputHelper.WriteLine($"Total snapshots captured: {urlSnapshots.Count}");
        for (var i = 0; i < urlSnapshots.Count; i++)
        {
            testOutputHelper.WriteLine($"  [{i}] URLs: {FormatUrls(urlSnapshots[i])}");
        }
 
        // Find snapshots for each lifecycle stage
        var initialized = urlSnapshots.FirstOrDefault(s => s.Length == 1);
        Assert.True(initialized is not null, $"Expected 'initialized' snapshot (1 URL) but none found. Captured {urlSnapshots.Count} snapshots.");
 
        var endpointsAllocated = urlSnapshots.FirstOrDefault(s => s.Length == 2 && s.Any(u => u.IsInactive));
        Assert.True(endpointsAllocated is not null, $"Expected 'endpointsAllocated' snapshot (2 URLs, some inactive) but none found. Captured {urlSnapshots.Count} snapshots.");
 
        var running = urlSnapshots.FirstOrDefault(s => s.Length == 2 && s.All(u => !u.IsInactive));
        Assert.True(running is not null, $"Expected 'running' snapshot (2 URLs, all active) but none found. Captured {urlSnapshots.Count} snapshots.");
 
        // Assert initialized: only static URL, active
        var initUrl = Assert.Single(initialized);
        Assert.False(initUrl.IsInactive);
        Assert.Null(initUrl.Name);
        Assert.Equal("https://example.com/project", initUrl.Url);
 
        // Assert endpoints allocated: endpoint URL inactive, static URL active
        Assert.Collection(endpointsAllocated,
            s => { Assert.True(s.IsInactive); Assert.NotNull(s.Name); Assert.StartsWith("http://localhost", s.Url); },
            s => { Assert.False(s.IsInactive); Assert.Null(s.Name); Assert.Equal("https://example.com/project", s.Url); }
        );
 
        // Assert running: both URLs active
        Assert.Collection(running,
            s => { Assert.False(s.IsInactive); Assert.NotNull(s.Name); Assert.StartsWith("http://localhost", s.Url); },
            s => { Assert.False(s.IsInactive); Assert.Null(s.Name); Assert.Equal("https://example.com/project", s.Url); }
        );
    }
 
    [Fact]
    public async Task CustomResourceUrlsTransitionThroughExpectedLifecycleStates()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var custom = builder.AddResource(new CustomResource("custom"))
            .WithHttpEndpoint()
            .WithUrl("https://example.com/custom")
            .WithInitialState(new()
            {
                ResourceType = "Custom",
                CreationTimeStamp = DateTime.UtcNow,
                State = KnownResourceStates.NotStarted,
                Properties = []
            })
            .OnInitializeResource(async (custom, e, ct) =>
            {
                // Mark all the endpoints on custom resource as allocated so that the URLs are initialized
                if (custom.TryGetEndpoints(out var endpoints))
                {
                    var startingPort = 1234;
                    foreach (var endpoint in endpoints)
                    {
                        endpoint.AllocatedEndpoint = new(endpoint, endpoint.TargetHost, endpoint.Port ?? endpoint.TargetPort ?? startingPort++);
                    }
                }
 
                // Publish the ResourceEndpointsAllocatedEvent for the resource
                await e.Eventing.PublishAsync(new ResourceEndpointsAllocatedEvent(custom, e.Services), EventDispatchBehavior.BlockingConcurrent, ct);
 
                // Publish the BeforeResourceStartedEvent for the resource
                await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(custom, e.Services), EventDispatchBehavior.BlockingSequential, ct);
 
                // Mark all the endpoint URLs as active (this makes them visible in the dashboard)
                await e.Notifications.PublishUpdateAsync(custom, s => s with
                {
                    Urls = [.. s.Urls.Select(u => u with { IsInactive = false })]
                });
 
                // Move resource to the running state
                await e.Services.GetRequiredService<ResourceNotificationService>()
                    .PublishUpdateAsync(e.Resource, s => s with
                    {
                        StartTimeStamp = DateTime.UtcNow,
                        State = KnownResourceStates.Running
                    });
            });
 
        await using var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration);
 
        var urlSnapshots = new List<UrlSnapshot[]>();
 
        static string FormatUrls(IEnumerable<UrlSnapshot> urls) =>
            string.Join(", ", urls.Select(u => $"[{u.Name ?? "null"}] {u.Url} (inactive={u.IsInactive})"));
 
        var watchTask = Task.Run(async () =>
        {
            await foreach (var notification in rns.WatchAsync(cts.Token))
            {
                if (notification.Resource == custom.Resource && notification.Snapshot.Urls.Length > 0)
                {
                    urlSnapshots.Add(notification.Snapshot.Urls.ToArray());
                    testOutputHelper.WriteLine($"Captured snapshot #{urlSnapshots.Count}: State={notification.Snapshot.State}, URLs: {FormatUrls(notification.Snapshot.Urls)}");
 
                    // Stop when running and all URLs are active
                    if (notification.Snapshot.State == KnownResourceStates.Running &&
                        notification.Snapshot.Urls.All(u => !u.IsInactive))
                    {
                        break;
                    }
                }
            }
        });
 
        await app.StartAsync();
        await rns.WaitForResourceAsync(custom.Resource.Name, KnownResourceStates.Running, cts.Token);
        await watchTask;
        await app.StopAsync().DefaultTimeout();
 
        // Log all captured snapshots for diagnostics
        testOutputHelper.WriteLine($"Total snapshots captured: {urlSnapshots.Count}");
        for (var i = 0; i < urlSnapshots.Count; i++)
        {
            testOutputHelper.WriteLine($"  [{i}] URLs: {FormatUrls(urlSnapshots[i])}");
        }
 
        // Find snapshots for each lifecycle stage
        var initialized = urlSnapshots.FirstOrDefault(s => s.Length == 1);
        Assert.True(initialized is not null, $"Expected 'initialized' snapshot (1 URL) but none found. Captured {urlSnapshots.Count} snapshots.");
 
        var endpointsAllocated = urlSnapshots.FirstOrDefault(s => s.Length == 2 && s.Any(u => u.IsInactive));
        Assert.True(endpointsAllocated is not null, $"Expected 'endpointsAllocated' snapshot (2 URLs, some inactive) but none found. Captured {urlSnapshots.Count} snapshots.");
 
        var running = urlSnapshots.FirstOrDefault(s => s.Length == 2 && s.All(u => !u.IsInactive));
        Assert.True(running is not null, $"Expected 'running' snapshot (2 URLs, all active) but none found. Captured {urlSnapshots.Count} snapshots.");
 
        // Assert initialized: only static URL, active
        var initUrl = Assert.Single(initialized);
        Assert.False(initUrl.IsInactive);
        Assert.Null(initUrl.Name);
        Assert.Equal("https://example.com/custom", initUrl.Url);
 
        // Assert endpoints allocated: endpoint URL inactive, static URL active
        Assert.Collection(endpointsAllocated,
            s => { Assert.True(s.IsInactive); Assert.NotNull(s.Name); Assert.StartsWith("http://localhost", s.Url); },
            s => { Assert.False(s.IsInactive); Assert.Null(s.Name); Assert.Equal("https://example.com/custom", s.Url); }
        );
 
        // Assert running: both URLs active
        Assert.Collection(running,
            s => { Assert.False(s.IsInactive); Assert.NotNull(s.Name); Assert.StartsWith("http://localhost", s.Url); },
            s => { Assert.False(s.IsInactive); Assert.Null(s.Name); Assert.Equal("https://example.com/custom", s.Url); }
        );
    }
 
    [Fact]
    public async Task UrlsAreMarkedAsInternalDependingOnDisplayLocation()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        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 });
            });
 
        await using var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        await app.StartAsync();
 
        // Wait for running state with multiple URLs
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
        var resourceEvent = await rns.WaitForResourceAsync(
            "servicea",
            e => e.Snapshot.State == KnownResourceStates.Running && e.Snapshot.Urls.Length > 1,
            cts.Token);
 
        await app.StopAsync().DefaultTimeout();
 
        Assert.Collection(resourceEvent.Snapshot.Urls,
            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(testOutputHelper);
 
        var called = false;
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("non-existant", u =>
            {
                called = true;
            })
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        Assert.False(called);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlForEndpointAddDoesNotThrowOrCallCallbackIfEndpointNotFound()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var called = false;
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("non-existant", ep =>
            {
                called = true;
                return new() { Url = "https://example.com" };
            })
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        Assert.False(called);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlWithRelativeUrlAppliesPathToExpectedUrls()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrl("https://static-before.com")
            .WithUrls(c => c.Urls.Add(new() { Url = "https://callback-before.com/sub-path", DisplayText = "Example" }))
            .WithUrl("/test", "Example") // This should update all URLs added to this point
            .WithUrl("https://static-after.com/sub-path") // This will get updated too because it's a static URL so order doesn't matter
            .WithUrls(c => c.Urls.Add(new() { Url = "https://callback-after.com/sub-path" })) // This won't get updated because it's added after the relative URL
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        var allUrls = projectA.Resource.Annotations.OfType<ResourceUrlAnnotation>();
        var endpointUrl = allUrls.FirstOrDefault(u => u.Endpoint?.EndpointName == "test");
        var staticBeforeUrl = allUrls.FirstOrDefault(u => u.Endpoint is null && u.Url.StartsWith("https://static-before.com"));
        var callbackBeforeUrl = allUrls.FirstOrDefault(u => u.Endpoint is null && u.Url.StartsWith("https://callback-before.com"));
        var staticAfter = allUrls.FirstOrDefault(u => u.Endpoint is null && u.Url.StartsWith("https://static-after.com"));
        var callbackAfter = allUrls.FirstOrDefault(u => u.Endpoint is null && u.Url.StartsWith("https://callback-after.com"));
 
        Assert.NotNull(endpointUrl);
        Assert.Equal("Example", endpointUrl.DisplayText);
        Assert.True(endpointUrl.Url.StartsWith("http://localhost") && endpointUrl.Url.EndsWith("/test"));
 
        Assert.NotNull(staticBeforeUrl);
        Assert.Equal("https://static-before.com/test", staticBeforeUrl.Url);
 
        Assert.NotNull(callbackBeforeUrl);
        Assert.Equal("https://callback-before.com/test", callbackBeforeUrl.Url);
 
        Assert.NotNull(staticAfter);
        Assert.Equal("https://static-after.com/test", staticAfter.Url);
 
        Assert.NotNull(callbackAfter);
        Assert.Equal("https://callback-after.com/sub-path", callbackAfter.Url);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WithUrlForEndpointUpdateTurnsRelativeUrlIntoAbsoluteUrl(bool useHttps)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var project = builder.AddProject<ProjectA>("project");
 
        if (useHttps)
        {
            project.WithHttpsEndpoint(name: "test");
        }
        else
        {
            project.WithHttpEndpoint(name: "test");
        }
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        project
            .WithUrlForEndpoint("test", url =>
            {
                url.Url = "/test-sub-path";
                url.DisplayText = "Test Link";
            })
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        var endpointUrl = project.Resource.Annotations.OfType<ResourceUrlAnnotation>().FirstOrDefault(u => u.Endpoint?.EndpointName == "test");
 
        Assert.NotNull(endpointUrl);
        Assert.StartsWith(useHttps ? "https://localhost" : "http://localhost", endpointUrl.Url);
        Assert.EndsWith("/test-sub-path", endpointUrl.Url);
        Assert.Equal("Test Link", endpointUrl.DisplayText);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlForEndpointSupportsMultipleRelativeUrlsOnLaunchProfileEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var project = builder.AddProject<ProjectB>("project", launchProfileName: "http")
            // Update the URL from the launch profile
            .WithUrlForEndpoint("http", url =>
            {
                url.Url = "/test-sub-path";
                url.DisplayText = "Test Link";
            })
            // Add another relative URL
            .WithUrlForEndpoint("http", _ => new()
            {
                Url = "/test-another-sub-path",
                DisplayText = "Another Test Link"
            })
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        var launchProfileUrls = project.Resource.Annotations.OfType<ResourceUrlAnnotation>().Where(u => u.Endpoint?.EndpointName == "http");
        Assert.Collection(launchProfileUrls,
            url =>
            {
                Assert.StartsWith("http://localhost", url.Url);
                Assert.EndsWith("/test-sub-path", url.Url);
                Assert.Equal("Test Link", url.DisplayText);
            },
            url =>
            {
                Assert.StartsWith("http://localhost", url.Url);
                Assert.EndsWith("/test-another-sub-path", url.Url);
                Assert.Equal("Another Test Link", url.DisplayText);
            }
        );
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlForEndpointAddTurnsRelativeUrlIntoAbsoluteUrl()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrlForEndpoint("test", ep =>
            {
                return new() { Url = "/sub-path" };
            })
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        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().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlsTurnsRelativeEndpointUrlsIntoAbsoluteUrls()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectA = builder.AddProject<ProjectA>("projecta")
            .WithHttpEndpoint(name: "test")
            .WithUrls(c =>
            {
                c.Urls.Add(new() { Endpoint = c.GetEndpoint("test"), Url = "/sub-path" });
            })
            .OnBeforeResourceStarted((_, _, _) =>
            {
                tcs.SetResult();
                return Task.CompletedTask;
            });
 
        await using var app = await builder.BuildAsync();
        await app.StartAsync();
        await tcs.Task.DefaultTimeout();
 
        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().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithUrlCanAddUrlFromAnotherResourcesEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        // Resource A has the endpoint
        var resourceA = builder.AddProject<Projects.ServiceA>("resourcea")
            .WithHttpEndpoint(name: "api");
 
        // Resource B gets a URL that references resource A's endpoint via the object model
        var resourceB = builder.AddProject<Projects.ServiceA>("resourceb")
            .WaitFor(resourceA)
            .WithUrls(c =>
            {
                c.Urls.Add(new()
                {
                    DisplayText = "API Docs",
                    Url = "/",
                    Endpoint = resourceA.Resource.GetEndpoint("api")
                });
            });
 
        await using var app = await builder.BuildAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        await app.StartAsync();
 
        // Wait for resource B to be running & have the expected URLs
        var resourceEvent = await rns.WaitForResourceAsync(
            resourceB.Resource.Name,
            e => e.Snapshot.State == KnownResourceStates.Running
                    && e.Snapshot.Urls.Length == resourceB.Resource.GetEndpoints().ToArray().Length + 1
                    && e.Snapshot.Urls.All(u => !u.IsInactive),
            default).DefaultTimeout();
 
        await app.StopAsync().DefaultTimeout();
 
        // Verify that the URL from resource A's endpoint appears in resource B's snapshot
        var crossResourceUrl = resourceEvent.Snapshot.Urls.FirstOrDefault(u => u.DisplayProperties.DisplayName == "API Docs");
 
        Assert.NotNull(crossResourceUrl);
        Assert.StartsWith("http://localhost", crossResourceUrl.Url);
        Assert.Equal("api", crossResourceUrl.Name);
        Assert.False(crossResourceUrl.IsInactive);
    }
 
    private sealed class CustomResource(string name) : Resource(name), IResourceWithEndpoints
    {
 
    }
 
    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"
                }
            }
        };
    }
}