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;
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
        {
            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 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);
 
        using var app = builder.Build();
        var healthCheck = new DevTunnelHealthCheck(
            client,
            app.Services.GetRequiredService<LoggedOutNotificationManager>(),
            tunnel.Resource,
            app.Services.GetRequiredService<ILogger<DevTunnelHealthCheck>>());
 
        var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()).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);
    }
 
    [Fact]
    public async Task DcpStartupPublishesDevTunnelUrls()
    {
        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();
        builder.Configuration["ASPIRE_DEVTUNNEL_CLI_PATH"] = command;
        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);
        foreach (var annotation in tunnel.Resource.Annotations.OfType<CommandLineArgsCallbackAnnotation>().ToArray())
        {
            tunnel.Resource.Annotations.Remove(annotation);
        }
        tunnel.WithArgs(arguments);
 
        using var app = builder.Build();
 
        var startTask = app.StartAsync(cts.Token);
        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;
 
        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.StopAsync(cts.Token);
    }
 
    [Fact]
    public async Task ResourceReady_PublishesUrlProperties()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        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);
        tunnel.Resource.LastKnownStatus = new("tunnel", HostConnections: 1, ClientConnections: 0, Description: "", Labels: []);
        port.LastKnownStatus = new DevTunnelPort(3000, "http")
        {
            PortUri = new Uri("https://n4skq32k-3000.use.devtunnels.ms/")
        };
 
        using var app = builder.Build();
        await builder.Eventing.PublishAsync(new ResourceReadyEvent(tunnel.Resource, app.Services)).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());
    }
}