File: LocalhostTldHostnameTests.cs
Web Access
Project: src\tests\Aspire.Templates.Tests\Aspire.Templates.Tests.csproj (Aspire.Templates.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using System.Text.RegularExpressions;
using Xunit;
 
namespace Aspire.Templates.Tests;
 
public partial class LocalhostTldHostnameTests(ITestOutputHelper testOutput) : TemplateTestsBase(testOutput)
{
    [GeneratedRegex(@"://([^:]+)\.dev\.localhost:")]
    private static partial Regex HostnamePattern();
 
    public static TheoryData<string, string, string, TestTargetFramework> LocalhostTldHostname_TestData()
    {
        TheoryData<string, string, string, TestTargetFramework> data = [];
 
        foreach (var framework in new[] { TestTargetFramework.Net10, TestTargetFramework.Net11 })
        {
            data.Add("aspire", "my.namespace.app", "my-namespace-app", framework);
            data.Add("aspire", ".StartWithDot", "startwithdot", framework);
            data.Add("aspire", "EndWithDot.", "endwithdot", framework);
            data.Add("aspire", "My..Test__Project", "my-test-project", framework);
            data.Add("aspire", "Project123.Test456", "project123-test456", framework);
            data.Add("aspire-apphost", "my.service.name", "my-service-name", framework);
            data.Add("aspire-apphost-singlefile", "-my.service..name-", "my-service-name", framework);
            data.Add("aspire-starter", "Test_App.1", "test-app-1", framework);
            data.Add("aspire-ts-cs-starter", "My-App.Test", "my-app-test", framework);
        }
 
        return data;
    }
 
    [Theory]
    [MemberData(nameof(LocalhostTldHostname_TestData))]
    [Trait("category", "basic-build")]
    public async Task LocalhostTld_GeneratesDnsCompliantHostnames(string templateName, string projectName, string expectedHostname, TestTargetFramework framework)
    {
        var id = GetNewProjectId(prefix: $"localhost_tld_{templateName}");
 
        var buildEnvironment = framework switch
        {
            TestTargetFramework.Net10 => BuildEnvironment.ForNet10SdkOnly,
            TestTargetFramework.Net11 => BuildEnvironment.ForNet11SdkOnly,
            _ => throw new ArgumentOutOfRangeException(nameof(framework))
        };
 
        // Single-file AppHosts have no project file for the helper to validate, so pass -f directly.
        var targetFramework = templateName == "aspire-apphost-singlefile" ? TestTargetFramework.None : framework;
        var frameworkArg = templateName == "aspire-apphost-singlefile" ? $"-f {framework.ToTFMString()} " : "";
 
        await using var project = await AspireProject.CreateNewTemplateProjectAsync(
            id,
            templateName,
            _testOutput,
            buildEnvironment: buildEnvironment,
            extraArgs: $"{frameworkArg}--localhost-tld -n \"{projectName}\"",
            targetFramework,
            addEndpointsHook: false); // Don't add endpoint hook since we're just checking file generation
 
        // When using -n, the template still outputs to the -o directory (id),
        // but the project names inside use the -n value
        // Find the launchSettings.json file - it will be in a directory named after the project
        var launchSettingsPath = templateName switch
        {
            "aspire-ts-cs-starter" or "aspire-starter" => Path.Combine(project.RootDir, $"{projectName}.AppHost", "Properties", "launchSettings.json"),
            "aspire" => Path.Combine(project.RootDir, $"{projectName}.AppHost", "Properties", "launchSettings.json"),
            "aspire-apphost" => Path.Combine(project.RootDir, "Properties", "launchSettings.json"),
            "aspire-apphost-singlefile" => Path.Combine(project.RootDir, "apphost.run.json"),
            _ => throw new ArgumentException($"Unknown template: {templateName}")
        };
 
        Assert.True(File.Exists(launchSettingsPath), $"launchSettings.json/apphost.run.json file not found at {launchSettingsPath}");
 
        var launchSettingsContent = await File.ReadAllTextAsync(launchSettingsPath);
        using var launchSettings = JsonDocument.Parse(launchSettingsContent);
 
        var profiles = launchSettings.RootElement.GetProperty("profiles");
 
        var foundDevLocalhost = false;
        foreach (var profile in profiles.EnumerateObject())
        {
            if (profile.Value.TryGetProperty("applicationUrl", out var applicationUrl))
            {
                var urls = applicationUrl.GetString();
                if (!string.IsNullOrEmpty(urls) && urls.Contains(".dev.localhost:"))
                {
                    foundDevLocalhost = true;
 
                    // Verify the hostname in the URL matches expected DNS-compliant format
                    Assert.Contains($"{expectedHostname}.dev.localhost:", urls);
 
                    // Verify no underscores in hostname (RFC 952/1123 compliance)
                    var matches = HostnamePattern().Matches(urls);
                    foreach (Match match in matches)
                    {
                        var hostname = match.Groups[1].Value;
                        Assert.DoesNotContain("_", hostname, StringComparison.Ordinal);
                        Assert.DoesNotContain(".", hostname, StringComparison.Ordinal);
                        Assert.False(hostname.StartsWith("-", StringComparison.Ordinal),
                            $"Hostname '{hostname}' should not start with hyphen (RFC 952/1123 violation)");
                        Assert.False(hostname.EndsWith("-", StringComparison.Ordinal),
                            $"Hostname '{hostname}' should not end with hyphen (RFC 952/1123 violation)");
                    }
                }
            }
        }
 
        Assert.True(foundDevLocalhost, "No .dev.localhost URLs found in launchSettings.json");
    }
}