File: DevTunnelResourceBuilderExtensionsTests.cs
Web Access
Project: src\tests\Aspire.Hosting.DevTunnels.Tests\Aspire.Hosting.DevTunnels.Tests.csproj (Aspire.Hosting.DevTunnels.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRECOMMAND001 // Required command validation APIs are experimental.
#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.DevTunnels.Tests;
 
public class DevTunnelResourceBuilderExtensionsTests
{
    [Fact]
    public async Task WithReference_InjectsServiceDiscoveryEnvironmentVariablesWhenReferencingOtherResourcesViaTheTunnel()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpsEndpoint();
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
        var consumer = builder.AddResource(new TestResource("consumer"))
            .WithReference(target, tunnel);
 
        var tunnelPort = tunnel.Resource.Ports.FirstOrDefault();
        Assert.NotNull(tunnelPort);
 
        tunnelPort.TunnelEndpointAnnotation.AllocatedEndpoint = new(tunnelPort.TunnelEndpointAnnotation, "test123.devtunnels.ms", 443);
 
        var values = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(consumer.Resource, serviceProvider: builder.Services.BuildServiceProvider()).DefaultTimeout();
 
        Assert.Equal("https://test123.devtunnels.ms:443", values["services__target__https__0"]);
        Assert.Equal("https://test123.devtunnels.ms:443", values["TARGET_HTTPS"]);
    }
 
    [Fact]
    public void AddDevTunnel_WithAnonymousAccess_SetsAllowAnonymousOption()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithAnonymousAccess();
 
        Assert.True(tunnel.Resource.Options.AllowAnonymous);
    }
 
    [Fact]
    public void AddDevTunnel_WithSpecificTunnelId_SetsTunnelIdProperty()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var tunnel = builder.AddDevTunnel("tunnel", "custom-id");
 
        Assert.Equal("custom-id", tunnel.Resource.TunnelId);
    }
 
    [Fact]
    public void AddDevTunnel_WithPersistentLifetime_AddsPersistenceAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var tunnel = builder.AddDevTunnel("tunnel", "custom-id")
            .WithPersistentLifetime();
 
        var annotation = Assert.Single(tunnel.Resource.Annotations.OfType<PersistenceAnnotation>());
        Assert.Equal(PersistenceMode.Persistent, annotation.Mode);
    }
 
    [Fact]
    public void AddDevTunnel_DefaultLifetimeDoesNotAddPersistenceAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var tunnel = builder.AddDevTunnel("tunnel", "custom-id");
 
        Assert.Empty(tunnel.Resource.Annotations.OfType<PersistenceAnnotation>());
    }
 
    [Fact]
    public void WithReference_WithAnonymousAccess_SetsPortAllowAnonymousOption()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpsEndpoint();
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target, allowAnonymous: true);
 
        Assert.Single(tunnel.Resource.Ports);
        var port = tunnel.Resource.Ports.First();
        Assert.True(port.Options.AllowAnonymous);
    }
 
    [Fact]
    public async Task WithReference_UsesTargetPortForDevTunnelPortWhenAvailable()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(port: 5000, targetPort: 5001, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var tunnelPort = Assert.Single(tunnel.Resource.Ports);
 
        Assert.Equal(5001, await tunnelPort.GetTunnelPortAsync());
    }
 
    [Fact]
    public async Task WithReference_UsesAllocatedPortForContainerDevTunnelPort()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddContainer("target", "image")
            .WithHttpEndpoint(port: 5000, targetPort: 8080, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var tunnelPort = Assert.Single(tunnel.Resource.Ports);
        tunnelPort.TargetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(
            tunnelPort.TargetEndpoint.EndpointAnnotation,
            "localhost",
            5000);
 
        Assert.Equal(5000, await tunnelPort.GetTunnelPortAsync());
    }
 
    [Fact]
    public async Task WithReference_ResolvesDynamicTargetPortForDevTunnelPortWhenAvailable()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(port: 5000, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var tunnelPort = Assert.Single(tunnel.Resource.Ports);
        tunnelPort.TargetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(
            tunnelPort.TargetEndpoint.EndpointAnnotation,
            "localhost",
            5000,
            targetPortExpression: "5001");
 
        Assert.Equal(5001, await tunnelPort.GetTunnelPortAsync());
    }
 
    [Fact]
    public async Task WithReference_UsesAllocatedPortForDevTunnelPortWhenTargetPortIsUnavailable()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(port: 5000, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var tunnelPort = Assert.Single(tunnel.Resource.Ports);
        tunnelPort.TargetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(
            tunnelPort.TargetEndpoint.EndpointAnnotation,
            "localhost",
            5000);
 
        Assert.Equal(5000, await tunnelPort.GetTunnelPortAsync());
    }
 
    [Fact]
    public async Task AddDevTunnel_WithRegion_UsesResolvedTunnelIdForExecutableArgs()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var tunnel = builder.AddDevTunnel("tunnel", "mytunnel", new DevTunnelOptions
        {
            Region = DevTunnelRegion.NorthEurope
        });
 
#pragma warning disable CS0618 // Type or member is obsolete
        var args = await tunnel.Resource.GetArgumentValuesAsync().DefaultTimeout();
#pragma warning restore CS0618 // Type or member is obsolete
 
        Assert.Equal(["host", "mytunnel.eun1", "--nologo"], args);
    }
 
    [Fact]
    public async Task OnBeforeResourceStarted_WithRegion_UsesResolvedTunnelIdForPortOperations()
    {
        var client = new TestDevTunnelClient
        {
            CreatedTunnelId = "mytunnel.eun1",
            PortList = new()
            {
                Ports = [
                    new(5001, "https"),
                    new(6000, "https")
                ]
            }
        };
 
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Services.AddSingleton<IDevTunnelClient>(client);
        builder.Services.AddSingleton<IRequiredCommandValidator, TestRequiredCommandValidator>();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(port: 5000, targetPort: 5001, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel", "mytunnel", new DevTunnelOptions
        {
            Region = DevTunnelRegion.NorthEurope
        }).WithReference(target);
        var tunnelPort = Assert.Single(tunnel.Resource.Ports);
        tunnelPort.TargetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(
            tunnelPort.TargetEndpoint.EndpointAnnotation,
            "localhost",
            5000);
 
        using var app = builder.Build();
 
        await builder.Eventing.PublishAsync(new BeforeResourceStartedEvent(tunnel.Resource, app.Services)).DefaultTimeout();
 
        var calls = client.Calls.ToArray();
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.CreateTunnelAsync) && call.TunnelId == "mytunnel");
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetPortListAsync) && call.TunnelId == "mytunnel.eun1");
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.CreatePortAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 5001);
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.DeletePortAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 6000);
    }
 
    [Fact]
    public async Task OnBeforeResourceStarted_WithAutoSelectedRegion_UsesCreatedTunnelIdForPortOperations()
    {
        var client = new TestDevTunnelClient
        {
            CreatedTunnelId = "mytunnel.eun1",
            PortList = new()
            {
                Ports = [
                    new(5001, "https"),
                    new(6000, "https")
                ]
            }
        };
 
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Services.AddSingleton<IDevTunnelClient>(client);
        builder.Services.AddSingleton<IRequiredCommandValidator, TestRequiredCommandValidator>();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(port: 5000, targetPort: 5001, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel", "mytunnel")
            .WithReference(target);
        var tunnelPort = Assert.Single(tunnel.Resource.Ports);
        tunnelPort.TargetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(
            tunnelPort.TargetEndpoint.EndpointAnnotation,
            "localhost",
            5000);
 
        using var app = builder.Build();
 
        await builder.Eventing.PublishAsync(new BeforeResourceStartedEvent(tunnel.Resource, app.Services)).DefaultTimeout();
 
        var calls = client.Calls.ToArray();
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.CreateTunnelAsync) && call.TunnelId == "mytunnel");
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetPortListAsync) && call.TunnelId == "mytunnel.eun1");
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.CreatePortAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 5001);
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.DeletePortAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 6000);
    }
 
    [Fact]
    public async Task DevTunnelHealthCheck_WithAutoSelectedRegion_UsesReturnedTunnelIdForAccessOperations()
    {
        var client = new TestDevTunnelClient
        {
            TunnelStatus = new("mytunnel.eun1", HostConnections: 1, ClientConnections: 0, Description: "", Labels: [])
            {
                Ports = [
                    new(5001, "https")
                    {
                        PortUri = new("https://mytunnel-5001.eun1.devtunnels.ms")
                    }
                ]
            }
        };
 
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Services.AddSingleton<IDevTunnelClient>(client);
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(port: 5000, targetPort: 5001, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel", "mytunnel")
            .WithReference(target);
 
        var port = Assert.Single(tunnel.Resource.Ports);
        port.TargetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(port.TargetEndpoint.EndpointAnnotation, "localhost", 5000);
        using var app = builder.Build();
        var monitor = app.Services.GetRequiredKeyedService<DevTunnelMonitor>(tunnel.Resource);
        monitor.StartupLogTimeout = TimeSpan.Zero;
        await monitor.StartAsync("mytunnel", CancellationToken.None);
        await monitor.ProcessLogAsync("Connection to host tunnel relay restored.", CancellationToken.None);
        var healthCheck = new DevTunnelHealthCheck(monitor);
 
        var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()).DefaultTimeout();
        await app.ResourceNotifications.WaitForResourceAsync(port.Name,
            e => e.Snapshot.Properties.Any(p => p.Name == "Anonymous access")).DefaultTimeout();
 
        Assert.Equal(HealthStatus.Healthy, result.Status);
        Assert.Collection(
            client.Calls,
            call =>
            {
                Assert.Equal(nameof(IDevTunnelClient.GetTunnelAsync), call.Method);
                Assert.Equal("mytunnel", call.TunnelId);
                Assert.Null(call.PortNumber);
            },
            call =>
            {
                Assert.Equal(nameof(IDevTunnelClient.GetAccessAsync), call.Method);
                Assert.Equal("mytunnel.eun1", call.TunnelId);
                Assert.Null(call.PortNumber);
            },
            call =>
            {
                Assert.Equal(nameof(IDevTunnelClient.GetAccessAsync), call.Method);
                Assert.Equal("mytunnel.eun1", call.TunnelId);
                Assert.Equal(5001, call.PortNumber);
            });
    }
 
    [Fact]
    public async Task DevTunnelHealthCheck_WithRegion_UsesResolvedTunnelIdForTunnelAndAccessOperations()
    {
        var client = new TestDevTunnelClient
        {
            TunnelStatus = new("mytunnel.eun1", HostConnections: 1, ClientConnections: 0, Description: "", Labels: [])
            {
                Ports = [
                    new(5001, "https")
                    {
                        PortUri = new("https://mytunnel-5001.devtunnels.ms")
                    }
                ]
            }
        };
 
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Services.AddSingleton<IDevTunnelClient>(client);
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(port: 5000, targetPort: 5001, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel", "mytunnel", new DevTunnelOptions
        {
            Region = DevTunnelRegion.NorthEurope
        }).WithReference(target);
 
        var port = Assert.Single(tunnel.Resource.Ports);
        port.TargetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(port.TargetEndpoint.EndpointAnnotation, "localhost", 5000);
        using var app = builder.Build();
        var monitor = app.Services.GetRequiredKeyedService<DevTunnelMonitor>(tunnel.Resource);
        monitor.StartupLogTimeout = TimeSpan.Zero;
        await monitor.StartAsync("mytunnel.eun1", CancellationToken.None);
        await monitor.ProcessLogAsync("Connection to host tunnel relay restored.", CancellationToken.None);
        var healthCheck = new DevTunnelHealthCheck(monitor);
 
        var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()).DefaultTimeout();
        await app.ResourceNotifications.WaitForResourceAsync(port.Name,
            e => e.Snapshot.Properties.Any(p => p.Name == "Anonymous access")).DefaultTimeout();
 
        Assert.Equal(HealthStatus.Healthy, result.Status);
        var calls = client.Calls.ToArray();
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetTunnelAsync) && call.TunnelId == "mytunnel.eun1");
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetAccessAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber is null);
        Assert.Contains(calls, call => call.Method == nameof(IDevTunnelClient.GetAccessAsync) && call.TunnelId == "mytunnel.eun1" && call.PortNumber == 5001);
    }
 
    [Fact]
    public void GetEndpoint_WithResourceAndEndpointName_ReturnsTunnelEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpsEndpoint(name: "https");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var tunnelEndpoint = tunnel.GetEndpoint(target.Resource, "https");
 
        Assert.NotNull(tunnelEndpoint);
        Assert.Equal(target.Resource, tunnelEndpoint.Resource);
        Assert.Equal(DevTunnelPortResource.TunnelEndpointName, tunnelEndpoint.EndpointName);
    }
 
    [Fact]
    public void GetEndpoint_WithResourceBuilderAndEndpointName_ReturnsTunnelEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpsEndpoint(name: "https");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var tunnelEndpoint = tunnel.GetEndpoint(target, "https");
 
        Assert.NotNull(tunnelEndpoint);
        Assert.Equal(target.Resource, tunnelEndpoint.Resource);
        Assert.Equal(DevTunnelPortResource.TunnelEndpointName, tunnelEndpoint.EndpointName);
    }
 
    [Fact]
    public void GetEndpoint_WithEndpointReference_ReturnsTunnelEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpsEndpoint(name: "https");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var targetEndpoint = target.GetEndpoint("https");
        var tunnelEndpoint = tunnel.GetEndpoint(targetEndpoint);
 
        Assert.NotNull(tunnelEndpoint);
        Assert.Equal(target.Resource, tunnelEndpoint.Resource);
        Assert.Equal(DevTunnelPortResource.TunnelEndpointName, tunnelEndpoint.EndpointName);
    }
 
    [Fact]
    public void GetEndpoint_WithResourceAndEndpointName_ReturnsEndpointWithErrorWhenEndpointNotFound()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpsEndpoint(name: "https");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var endpointRef = tunnel.GetEndpoint(target.Resource, "nonexistent");
 
        Assert.NotNull(endpointRef);
        Assert.False(endpointRef.Exists);
 
        var ex = Assert.Throws<InvalidOperationException>(() => _ = endpointRef.EndpointAnnotation);
        Assert.Equal("The dev tunnel 'tunnel' has not been associated with 'nonexistent' on resource 'target'. Use 'WithReference(target)' on the dev tunnel to expose this endpoint.", ex.Message);
    }
 
    [Fact]
    public void GetEndpoint_WithEndpointReference_ReturnsEndpointWithErrorWhenEndpointNotFound()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpsEndpoint(name: "https");
        var target2 = builder.AddProject<ProjectA>("target2")
            .WithHttpsEndpoint(name: "https");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var target2Endpoint = target2.GetEndpoint("https");
        var endpointRef = tunnel.GetEndpoint(target2Endpoint);
 
        Assert.NotNull(endpointRef);
        Assert.False(endpointRef.Exists);
 
        var ex = Assert.Throws<InvalidOperationException>(() => _ = endpointRef.EndpointAnnotation);
        Assert.Equal("The dev tunnel 'tunnel' has not been associated with 'https' on resource 'target2'. Use 'WithReference(target2)' on the dev tunnel to expose this endpoint.", ex.Message);
    }
 
    [Fact]
    public void GetEndpoint_WithResourceAndEndpointName_ReturnsEndpointWithErrorWhenResourceNotReferenced()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpsEndpoint(name: "https");
        var tunnel = builder.AddDevTunnel("tunnel");
 
        var endpointRef = tunnel.GetEndpoint(target.Resource, "https");
 
        Assert.NotNull(endpointRef);
        Assert.False(endpointRef.Exists);
 
        var ex = Assert.Throws<InvalidOperationException>(() => _ = endpointRef.EndpointAnnotation);
        Assert.Equal("The dev tunnel 'tunnel' has not been associated with 'https' on resource 'target'. Use 'WithReference(target)' on the dev tunnel to expose this endpoint.", ex.Message);
    }
 
    [Fact]
    public void GetEndpoint_WithMultipleEndpoints_ReturnsCorrectTunnelEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(name: "http")
            .WithHttpsEndpoint(name: "https");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
 
        var httpTunnelEndpoint = tunnel.GetEndpoint(target.Resource, "http");
        var httpsTunnelEndpoint = tunnel.GetEndpoint(target.Resource, "https");
 
        Assert.NotNull(httpTunnelEndpoint);
        Assert.NotNull(httpsTunnelEndpoint);
        Assert.Equal(DevTunnelPortResource.TunnelEndpointName, httpTunnelEndpoint.EndpointName);
        Assert.Equal(DevTunnelPortResource.TunnelEndpointName, httpsTunnelEndpoint.EndpointName);
 
        // Verify they reference different ports (implicitly through the annotation)
        Assert.NotSame(httpTunnelEndpoint.EndpointAnnotation, httpsTunnelEndpoint.EndpointAnnotation);
    }
 
    [Fact]
    public async Task ShowTunnelUrlsCommand_OpensInteractionWithRelevantUrls()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var interactionService = new TestInteractionService();
        builder.Services.AddSingleton<IInteractionService>(interactionService);
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(name: "http");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
        var port = Assert.Single(tunnel.Resource.Ports);
        var targetEndpoint = target.GetEndpoint("http");
        targetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(targetEndpoint.EndpointAnnotation, "localhost", 3000);
        port.LastKnownStatus = new DevTunnelPort(3000, "http")
        {
            PortUri = new Uri("https://n4skq32k-3000.use.devtunnels.ms/")
        };
 
        var command = Assert.Single(port.Annotations.OfType<ResourceCommandAnnotation>(), a => a.Name == DevTunnelPortResource.ShowTunnelUrlsCommandName);
        Assert.Equal("LinkMultiple", command.IconName);
        Assert.Equal(IconVariant.Regular, command.IconVariant);
        Assert.True(command.IsHighlighted);
        Assert.Equal(ResourceCommandVisibility.UI, command.Visibility);
        using var serviceProvider = builder.Services.BuildServiceProvider();
 
        var enabledState = command.UpdateState(new UpdateCommandStateContext
        {
            ResourceSnapshot = new()
            {
                ResourceType = "DevTunnelPort",
                Properties = [],
                State = KnownResourceStates.Running
            },
            Services = serviceProvider
        });
        var stoppedState = command.UpdateState(new UpdateCommandStateContext
        {
            ResourceSnapshot = new()
            {
                ResourceType = "DevTunnelPort",
                Properties = [],
                State = KnownResourceStates.Finished
            },
            Services = serviceProvider
        });
        interactionService.IsAvailable = false;
        var unavailableState = command.UpdateState(new UpdateCommandStateContext
        {
            ResourceSnapshot = new()
            {
                ResourceType = "DevTunnelPort",
                Properties = [],
                State = KnownResourceStates.Running
            },
            Services = serviceProvider
        });
        interactionService.IsAvailable = true;
        var commandTask = command.ExecuteCommand(new ExecuteCommandContext
        {
            ResourceName = port.Name,
            Services = serviceProvider,
            Arguments = new InteractionInputCollection([]),
            CancellationToken = CancellationToken.None,
            Logger = NullLogger.Instance
        });
 
        var interaction = await interactionService.Interactions.Reader.ReadAsync().AsTask().DefaultTimeout();
        var options = Assert.IsType<MessageBoxInteractionOptions>(interaction.Options);
        interaction.CompletionTcs.SetResult(InteractionResult.Ok(true));
        var result = await commandTask.DefaultTimeout();
 
        Assert.Equal(ResourceCommandState.Enabled, enabledState);
        Assert.Equal(ResourceCommandState.Disabled, stoppedState);
        Assert.Equal(ResourceCommandState.Disabled, unavailableState);
        Assert.Equal(InteractionType.MessageBox, interaction.Type);
        Assert.Equal("Dev tunnel URLs", interaction.Title);
        Assert.Equal(
            $"**Tunnel URL:** <https://n4skq32k-3000.use.devtunnels.ms>  {Environment.NewLine}" +
            $"**Inspect URL:** <https://n4skq32k-3000-inspect.use.devtunnels.ms>  {Environment.NewLine}" +
            "**Local endpoint URL:** <http://localhost:3000>",
            interaction.Message);
        Assert.Equal(MessageIntent.None, options.Intent);
        Assert.True(options.EnableMessageMarkdown);
        Assert.Equal("Close", options.PrimaryButtonText);
        Assert.False(options.ShowSecondaryButton);
        Assert.True(result.Success);
        Assert.Null(result.Data);
    }
 
    [Fact]
    public async Task ShowTunnelUrlsCommand_OpensInteractionWithoutLocalEndpointAllocation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var interactionService = new TestInteractionService();
        builder.Services.AddSingleton<IInteractionService>(interactionService);
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(name: "http");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
        var port = Assert.Single(tunnel.Resource.Ports);
        port.LastKnownStatus = new DevTunnelPort(3000, "http")
        {
            PortUri = new Uri("https://n4skq32k-3000.use.devtunnels.ms/")
        };
 
        var command = Assert.Single(port.Annotations.OfType<ResourceCommandAnnotation>(), a => a.Name == DevTunnelPortResource.ShowTunnelUrlsCommandName);
        using var serviceProvider = builder.Services.BuildServiceProvider();
 
        var commandTask = command.ExecuteCommand(new ExecuteCommandContext
        {
            ResourceName = port.Name,
            Services = serviceProvider,
            Arguments = new InteractionInputCollection([]),
            CancellationToken = CancellationToken.None,
            Logger = NullLogger.Instance
        });
 
        var interaction = await interactionService.Interactions.Reader.ReadAsync().AsTask().DefaultTimeout();
        interaction.CompletionTcs.SetResult(InteractionResult.Ok(true));
        var result = await commandTask.DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Null(result.Data);
        Assert.Equal(
            $"**Tunnel URL:** <https://n4skq32k-3000.use.devtunnels.ms>  {Environment.NewLine}" +
            "**Inspect URL:** <https://n4skq32k-3000-inspect.use.devtunnels.ms>",
            interaction.Message);
    }
 
    [Fact]
    public async Task ShowTunnelUrlsCommand_UsesTargetEndpointNetworkContext()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var interactionService = new TestInteractionService();
        builder.Services.AddSingleton<IInteractionService>(interactionService);
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(name: "http");
        var targetEndpoint = target.GetEndpoint("http");
        targetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(targetEndpoint.EndpointAnnotation, "localhost", 3000);
        var containerNetwork = new NetworkIdentifier("container-network");
        targetEndpoint.EndpointAnnotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(
            containerNetwork,
            new(targetEndpoint.EndpointAnnotation, "target", 8080, EndpointBindingMode.SingleAddress, networkId: containerNetwork));
        var contextualTargetEndpoint = new EndpointReference(target.Resource, targetEndpoint.EndpointAnnotation, containerNetwork);
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(contextualTargetEndpoint);
        var port = Assert.Single(tunnel.Resource.Ports);
        port.LastKnownStatus = new DevTunnelPort(3000, "http")
        {
            PortUri = new Uri("https://n4skq32k-3000.use.devtunnels.ms/")
        };
 
        var command = Assert.Single(port.Annotations.OfType<ResourceCommandAnnotation>(), a => a.Name == DevTunnelPortResource.ShowTunnelUrlsCommandName);
        using var serviceProvider = builder.Services.BuildServiceProvider();
 
        var commandTask = command.ExecuteCommand(new ExecuteCommandContext
        {
            ResourceName = port.Name,
            Services = serviceProvider,
            Arguments = new InteractionInputCollection([]),
            CancellationToken = CancellationToken.None,
            Logger = NullLogger.Instance
        });
 
        var interaction = await interactionService.Interactions.Reader.ReadAsync().AsTask().DefaultTimeout();
        interaction.CompletionTcs.SetResult(InteractionResult.Ok(true));
        var result = await commandTask.DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Equal(
            $"**Tunnel URL:** <https://n4skq32k-3000.use.devtunnels.ms>  {Environment.NewLine}" +
            $"**Inspect URL:** <https://n4skq32k-3000-inspect.use.devtunnels.ms>  {Environment.NewLine}" +
            "**Local endpoint URL:** <http://target:8080>",
            interaction.Message);
    }
 
    [Theory]
    [InlineData(false, false, false)]
    [InlineData(true, false, false)]
    [InlineData(true, true, false)]
    [InlineData(true, true, true)]
    public async Task DcpStartupPublishesDevTunnelUrls(bool useConsoleOutput, bool stopDuringUrlCallback, bool restartBeforeUrlCallbackCompletes)
    {
        const int targetPort = 3000;
        const string tunnelUrl = "https://n4skq32k-3000.use.devtunnels.ms";
        const string inspectUrl = "https://n4skq32k-3000-inspect.use.devtunnels.ms";
        using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
        var client = new TestDevTunnelClient
        {
            TunnelStatus = new("mytunnel", HostConnections: 1, ClientConnections: 0, Description: "", Labels: [])
            {
                Ports =
                [
                    new(targetPort, "http")
                    {
                        PortUri = new Uri($"{tunnelUrl}/")
                    }
                ]
            }
        };
        var (command, arguments) = GetLongRunningCommand();
        using var builder = TestDistributedApplicationBuilder.Create();
        var tunnelCommand = OperatingSystem.IsWindows() ? command : "/bin/sh";
        builder.Configuration["ASPIRE_DEVTUNNEL_CLI_PATH"] = tunnelCommand;
        if (useConsoleOutput)
        {
            client.GetTunnelCallback = (_, _) => throw new InvalidOperationException("Startup should not query service status when host output is available.");
        }
        builder.Services.AddSingleton<IDevTunnelClient>(client);
        builder.Services.AddSingleton<IRequiredCommandValidator, TestRequiredCommandValidator>();
 
        var target = builder.AddExecutable("target", command, Environment.CurrentDirectory, arguments)
            .WithHttpEndpoint(targetPort: targetPort, name: "http");
        var tunnel = builder.AddDevTunnel("tunnel", "mytunnel")
            .WithReference(target);
        var tunnelPort = Assert.Single(tunnel.Resource.Ports);
        var urlCallbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseUrlCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        if (stopDuringUrlCallback)
        {
            builder.CreateResourceBuilder(tunnelPort).WithUrls(async context =>
            {
                urlCallbackStarted.TrySetResult();
                await releaseUrlCallback.Task.WaitAsync(context.CancellationToken);
                context.Urls.Add(new() { Url = "https://example.com/diagnostics", DisplayText = "Custom diagnostics" });
            });
        }
        foreach (var annotation in tunnel.Resource.Annotations.OfType<CommandLineArgsCallbackAnnotation>().ToArray())
        {
            tunnel.Resource.Annotations.Remove(annotation);
        }
        var portOutput = useConsoleOutput
            ? OperatingSystem.IsWindows()
                ? $" & echo Hosting port: {targetPort} & echo Connect via browser: {tunnelUrl} & echo Ready to accept connections for tunnel: mytunnel"
                : $" 'Hosting port: {targetPort}' 'Connect via browser: {tunnelUrl}' 'Ready to accept connections for tunnel: mytunnel'"
            : "";
        tunnel.WithArgs(OperatingSystem.IsWindows()
            ? ["/c", $"echo Connection to host tunnel relay restored.{portOutput} & ping -n 180 127.0.0.1"]
            : ["-c", $"printf '%s\\n' 'Connection to host tunnel relay restored.'{portOutput}; sleep 180"]);
 
        var dependent = builder.AddExecutable("dependent", command, Environment.CurrentDirectory, arguments)
            .WithExplicitStart()
            .WaitFor(builder.CreateResourceBuilder(tunnelPort));
        using var app = builder.Build();
 
        var startTask = app.StartAsync(cts.Token);
        if (stopDuringUrlCallback)
        {
            try
            {
                await urlCallbackStarted.Task.DefaultTimeout();
                await startTask;
                var stopped = await app.ResourceCommands.ExecuteCommandAsync(tunnel.Resource, "stop", cts.Token).DefaultTimeout();
                Assert.True(stopped.Success);
                await app.ResourceNotifications.WaitForResourceAsync(tunnelPort.Name, KnownResourceStates.Finished, cts.Token);
                if (restartBeforeUrlCallbackCompletes)
                {
                    var restarted = await app.ResourceCommands.ExecuteCommandAsync(tunnel.Resource, "start", cts.Token).DefaultTimeout();
                    Assert.True(restarted.Success);
                }
 
                releaseUrlCallback.TrySetResult();
                var expectedInactive = !restartBeforeUrlCallbackCompletes;
                var completed = await app.ResourceNotifications.WaitForResourceAsync(tunnelPort.Name,
                    e => e.Snapshot.Urls.Any(u => u.Url == inspectUrl)
                        && e.Snapshot.Urls.Any(u => u.Url == "https://example.com/diagnostics")
                        && e.Snapshot.Urls.All(u => u.IsInactive == expectedInactive), cts.Token);
                Assert.Equal(restartBeforeUrlCallbackCompletes ? KnownResourceStates.Running : KnownResourceStates.Finished, completed.Snapshot.State?.Text);
                Assert.True(completed.Snapshot.Urls.Length >= 3);
                if (restartBeforeUrlCallbackCompletes)
                {
                    await app.ResourceNotifications.WaitForResourceHealthyAsync(tunnel.Resource.Name, cts.Token);
                }
                else
                {
                    Assert.NotNull(completed.Snapshot.StopTimeStamp);
                }
            }
            finally
            {
                releaseUrlCallback.TrySetResult();
                await app.StopAsync(cts.Token);
            }
            return;
        }
        var resourceEvent = await app.ResourceNotifications.WaitForResourceAsync(
            tunnelPort.Name,
            e => e.Snapshot.State?.Text == KnownResourceStates.Running &&
                e.Snapshot.Urls.Any(u => u.Url == tunnelUrl && !u.IsInactive) &&
                e.Snapshot.Urls.Any(u => u.Url == inspectUrl && !u.IsInactive),
            cts.Token);
        await startTask;
        await app.ResourceNotifications.WaitForResourceHealthyAsync(tunnel.Resource.Name, cts.Token);
 
        Assert.Equal("n4skq32k-3000.use.devtunnels.ms", tunnelPort.TunnelEndpointAnnotation.AllocatedEndpoint?.Address);
        Assert.Contains(resourceEvent.Snapshot.Urls, u => u.Url == tunnelUrl && !u.IsInactive);
        Assert.Contains(resourceEvent.Snapshot.Urls, u => u.Url == inspectUrl && !u.IsInactive);
 
        await app.ResourceNotifications.WaitForResourceHealthyAsync(tunnelPort.Name, cts.Token);
        Assert.Single(tunnelPort.Annotations.OfType<HealthCheckAnnotation>());
        var monitor = app.Services.GetRequiredKeyedService<DevTunnelMonitor>(tunnel.Resource);
        await monitor.ProcessLogAsync("Connection to host tunnel relay closed.", cts.Token);
        var waitForDependency = app.ResourceNotifications.WaitForDependenciesAsync(dependent.Resource, cts.Token);
        Assert.False(waitForDependency.IsCompleted);
        await monitor.ProcessLogAsync("Connection to host tunnel relay restored.", cts.Token);
        await waitForDependency.DefaultTimeout();
 
        await app.StopAsync(cts.Token);
    }
 
    [Fact]
    public async Task Monitor_PublishesUrlProperties()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Services.AddSingleton<IDevTunnelClient>(new TestDevTunnelClient());
 
        var target = builder.AddProject<ProjectA>("target")
            .WithHttpEndpoint(name: "http");
        var tunnel = builder.AddDevTunnel("tunnel")
            .WithReference(target);
        var port = Assert.Single(tunnel.Resource.Ports);
        var targetEndpoint = target.GetEndpoint("http");
        targetEndpoint.EndpointAnnotation.AllocatedEndpoint = new(targetEndpoint.EndpointAnnotation, "localhost", 3000);
        using var app = builder.Build();
        var monitor = app.Services.GetRequiredKeyedService<DevTunnelMonitor>(tunnel.Resource);
        await monitor.StartAsync(tunnel.Resource.TunnelId, CancellationToken.None);
        await monitor.ProcessLogAsync("Hosting port 3000 at https://n4skq32k-3000.use.devtunnels.ms/", CancellationToken.None);
        await monitor.ProcessLogAsync($"Ready to accept connections for tunnel: {tunnel.Resource.TunnelId}", CancellationToken.None);
        await app.ResourceNotifications.WaitForResourceAsync(port.Name, KnownResourceStates.Running).DefaultTimeout();
 
        var notifications = app.Services.GetRequiredService<ResourceNotificationService>();
        Assert.True(notifications.TryGetCurrentState(port.Name, out var resourceEvent));
        Assert.Collection(
            resourceEvent.Snapshot.Properties.Where(p => p.Name is
                DevTunnelPortResource.TunnelUrlPropertyName or
                DevTunnelPortResource.InspectUrlPropertyName or
                DevTunnelPortResource.LocalEndpointUrlPropertyName),
            property =>
            {
                Assert.Equal(DevTunnelPortResource.TunnelUrlPropertyName, property.Name);
                Assert.Equal("Tunnel URL", property.DisplayName);
                Assert.Equal("https://n4skq32k-3000.use.devtunnels.ms", property.Value);
                Assert.True(property.IsHighlighted);
            },
            property =>
            {
                Assert.Equal(DevTunnelPortResource.InspectUrlPropertyName, property.Name);
                Assert.Equal("Inspect URL", property.DisplayName);
                Assert.Equal("https://n4skq32k-3000-inspect.use.devtunnels.ms", property.Value);
                Assert.True(property.IsHighlighted);
            },
            property =>
            {
                Assert.Equal(DevTunnelPortResource.LocalEndpointUrlPropertyName, property.Name);
                Assert.Equal("Local endpoint URL", property.DisplayName);
                Assert.Equal("http://localhost:3000", property.Value);
                Assert.True(property.IsHighlighted);
            });
    }
 
    private static (string Command, string[] Arguments) GetLongRunningCommand()
    {
        // Windows has no sleep executable, and `timeout` exits immediately when stdin is redirected.
        // Loopback ping is dependency-free and remains bounded if test cleanup is interrupted.
        return OperatingSystem.IsWindows()
            ? ("cmd.exe", ["/c", "ping", "-n", "180", "127.0.0.1"])
            : ("sleep", ["180"]);
    }
 
    private sealed class ProjectA : IProjectMetadata
    {
        public string ProjectPath => "projectA";
 
        public LaunchSettings LaunchSettings { get; } = new();
    }
 
    private sealed class TestResource(string name) : Resource(name), IResourceWithEnvironment
    {
 
    }
 
    private sealed class TestRequiredCommandValidator : IRequiredCommandValidator
    {
        public Task<RequiredCommandValidationResult> ValidateAsync(IResource resource, RequiredCommandAnnotation annotation, CancellationToken cancellationToken)
            => Task.FromResult(RequiredCommandValidationResult.Success());
    }
}