diff --git a/src/ProjectTemplates/Shared/ArgConstants.cs b/src/ProjectTemplates/Shared/ArgConstants.cs index eafa09fe6f86..7f680300aae9 100644 --- a/src/ProjectTemplates/Shared/ArgConstants.cs +++ b/src/ProjectTemplates/Shared/ArgConstants.cs @@ -24,6 +24,7 @@ internal static class ArgConstants public const string UseLocalDb = "-uld"; public const string NoHttps = "--no-https"; public const string PublishNativeAot = "--aot"; + public const string LocalhostTld = "--localhost-tld"; public const string NoInteractivity = "--interactivity none"; public const string WebAssemblyInteractivity = "--interactivity WebAssembly"; public const string AutoInteractivity = "--interactivity Auto"; diff --git a/src/ProjectTemplates/Shared/Project.cs b/src/ProjectTemplates/Shared/Project.cs index 308da1f9a8c8..7a2e752ddba3 100644 --- a/src/ProjectTemplates/Shared/Project.cs +++ b/src/ProjectTemplates/Shared/Project.cs @@ -71,6 +71,11 @@ internal async Task RunDotNetNewAsync( // Used to set special options in MSBuild IDictionary environmentVariables = null) { + if (templateName.Contains(' ')) + { + throw new ArgumentException("Template name cannot contain spaces."); + } + var hiveArg = $"--debug:disable-sdk-templates --debug:custom-hive \"{TemplatePackageInstaller.CustomHivePath}\""; var argString = $"new {templateName} {hiveArg}"; environmentVariables ??= new Dictionary(); @@ -111,6 +116,13 @@ internal async Task RunDotNetNewAsync( // We omit the hive argument and the template output dir as they are not relevant and add noise. ProjectArguments = argString.Replace(hiveArg, ""); + // Only add -n parameter if ProjectName is set and args doesn't already contain -n or --name + if (!string.IsNullOrEmpty(ProjectName) && + args?.Any(a => a.Contains("-n ") || a.Contains("--name ") || a == "-n" || a == "--name") != true) + { + argString += $" -n \"{ProjectName}\""; + } + argString += $" -o {TemplateOutputDir}"; if (Directory.Exists(TemplateOutputDir)) @@ -350,6 +362,42 @@ public async Task VerifyLaunchSettings(string[] expectedLaunchProfileNames) } } + public async Task VerifyDnsCompliantHostname(string expectedHostname) + { + var launchSettingsPath = Path.Combine(TemplateOutputDir, "Properties", "launchSettings.json"); + Assert.True(File.Exists(launchSettingsPath), $"launchSettings.json not found at {launchSettingsPath}"); + + var launchSettingsContent = await File.ReadAllTextAsync(launchSettingsPath); + using var launchSettings = JsonDocument.Parse(launchSettingsContent); + + var profiles = launchSettings.RootElement.GetProperty("profiles"); + + foreach (var profile in profiles.EnumerateObject()) + { + if (profile.Value.TryGetProperty("applicationUrl", out var applicationUrl)) + { + var urls = applicationUrl.GetString(); + if (!string.IsNullOrEmpty(urls)) + { + // 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 hostnamePattern = @"://([^:]+)\.dev\.localhost:"; + var matches = System.Text.RegularExpressions.Regex.Matches(urls, hostnamePattern); + foreach (System.Text.RegularExpressions.Match match in matches) + { + var hostname = match.Groups[1].Value; + Assert.DoesNotContain("_", hostname); + Assert.DoesNotContain(".", hostname); + 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)"); + } + } + } + } + } + public async Task VerifyHasProperty(string propertyName, string expectedValue) { var projectFile = Directory.EnumerateFiles(TemplateOutputDir, "*proj").FirstOrDefault(); diff --git a/src/ProjectTemplates/Shared/ProjectFactoryFixture.cs b/src/ProjectTemplates/Shared/ProjectFactoryFixture.cs index 0de11acd780e..30f9b617c067 100644 --- a/src/ProjectTemplates/Shared/ProjectFactoryFixture.cs +++ b/src/ProjectTemplates/Shared/ProjectFactoryFixture.cs @@ -39,7 +39,22 @@ public async Task CreateProject(ITestOutputHelper output) return project; } - private Project CreateProjectImpl(ITestOutputHelper output) + public async Task CreateProject(ITestOutputHelper output, string projectName) + { + await TemplatePackageInstaller.EnsureTemplatingEngineInitializedAsync(output); + + var project = CreateProjectImpl(output, projectName); + + var projectKey = Guid.NewGuid().ToString().Substring(0, 10).ToLowerInvariant(); + if (!_projects.TryAdd(projectKey, project)) + { + throw new InvalidOperationException($"Project key collision in {nameof(ProjectFactoryFixture)}.{nameof(CreateProject)}!"); + } + + return project; + } + + private Project CreateProjectImpl(ITestOutputHelper output, string projectName = null) { var project = new Project { @@ -49,11 +64,20 @@ private Project CreateProjectImpl(ITestOutputHelper output) // declarations (i.e. make it more stable for testing) ProjectGuid = GetRandomLetter() + Path.GetRandomFileName().Replace(".", string.Empty) }; - project.ProjectName = $"AspNet.{project.ProjectGuid}"; + + if (string.IsNullOrEmpty(projectName)) + { + project.ProjectName = $"AspNet.{project.ProjectGuid}"; + } + else + { + project.ProjectName = projectName; + } var assemblyPath = GetType().Assembly; var basePath = GetTemplateFolderBasePath(assemblyPath); - project.TemplateOutputDir = Path.Combine(basePath, project.ProjectName); + // Use ProjectGuid for directory to avoid filesystem issues with invalid characters in projectName + project.TemplateOutputDir = Path.Combine(basePath, $"AspNet.{project.ProjectGuid}"); return project; } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json index df16332f7827..9152b23c8be3 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json @@ -202,7 +202,39 @@ ] } ], + "forms": { + "lowerCaseInvariantWithHyphens": { + "identifier": "chain", + "steps": [ + "lowerCaseInvariant", + "replaceDnsInvalidChars", + "trimLeadingHyphens", + "trimTrailingHyphens" + ] + }, + "replaceDnsInvalidChars": { + "identifier": "replace", + "pattern": "[^a-z0-9-]", + "replacement": "-" + }, + "trimLeadingHyphens": { + "identifier": "replace", + "pattern": "^-+", + "replacement": "" + }, + "trimTrailingHyphens": { + "identifier": "replace", + "pattern": "-+$", + "replacement": "" + } + }, "symbols": { + "hostName": { + "type": "derived", + "valueSource": "name", + "valueTransform": "lowerCaseInvariantWithHyphens", + "replaces": "LocalhostTldHostNamePrefix" + }, "Framework": { "type": "parameter", "description": "The target framework for the project.", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Properties/launchSettings.json index e86aed770617..c4b5b76926e4 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Properties/launchSettings.json @@ -10,7 +10,7 @@ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", //#endif //#if (LocalhostTld) - "applicationUrl": "http://blazorwebcsharp__1.dev.localhost:5500", + "applicationUrl": "http://LocalhostTldHostNamePrefix.dev.localhost:5500", //#else "applicationUrl": "http://localhost:5500", //#endif @@ -32,7 +32,7 @@ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", //#endif //#if (LocalhostTld) - "applicationUrl": "https://blazorwebcsharp__1.dev.localhost:5501;http://blazorwebcsharp__1.dev.localhost:5500", + "applicationUrl": "https://LocalhostTldHostNamePrefix.dev.localhost:5501;http://LocalhostTldHostNamePrefix.dev.localhost:5500", //#else "applicationUrl": "https://localhost:5501;http://localhost:5500", //#endif diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/.template.config/template.json index b2249e8d99bc..cbc4c861d3c6 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/.template.config/template.json @@ -48,7 +48,39 @@ ] } ], + "forms": { + "lowerCaseInvariantWithHyphens": { + "identifier": "chain", + "steps": [ + "lowerCaseInvariant", + "replaceDnsInvalidChars", + "trimLeadingHyphens", + "trimTrailingHyphens" + ] + }, + "replaceDnsInvalidChars": { + "identifier": "replace", + "pattern": "[^a-z0-9-]", + "replacement": "-" + }, + "trimLeadingHyphens": { + "identifier": "replace", + "pattern": "^-+", + "replacement": "" + }, + "trimTrailingHyphens": { + "identifier": "replace", + "pattern": "-+$", + "replacement": "" + } + }, "symbols": { + "hostName": { + "type": "derived", + "valueSource": "name", + "valueTransform": "lowerCaseInvariantWithHyphens", + "replaces": "LocalhostTldHostNamePrefix" + }, "ExcludeLaunchSettings": { "type": "parameter", "datatype": "bool", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/Properties/launchSettings.json index ed5e05d0ab13..3ec9bd7f720f 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, //#if (LocalhostTld) - "applicationUrl": "http://company_webapplication1.dev.localhost:5000", + "applicationUrl": "http://LocalhostTldHostNamePrefix.dev.localhost:5000", //#else "applicationUrl": "http://localhost:5000", //#endif @@ -24,7 +24,7 @@ "dotnetRunMessages": true, "launchBrowser": true, //#if (LocalhostTld) - "applicationUrl": "https://company_webapplication1.dev.localhost:5001;http://company_webapplication1.dev.localhost:5000", + "applicationUrl": "https://LocalhostTldHostNamePrefix.dev.localhost:5001;http://LocalhostTldHostNamePrefix.dev.localhost:5000", //#else "applicationUrl": "https://localhost:5001;http://localhost:5000", //#endif diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs index cff83ab7b3fb..e4e511e5e19f 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; +using System.Text.Json; using Microsoft.AspNetCore.BrowserTesting; +using Microsoft.AspNetCore.InternalTesting; using Templates.Test.Helpers; namespace BlazorTemplates.Tests; @@ -88,4 +90,22 @@ private async Task TestProjectCoreAsync(Project project, BrowserKind browserKind await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude, authenticationFeatures); } } + + [ConditionalTheory] + [InlineData("my.namespace.blazor", "my-namespace-blazor")] + [InlineData(".StartWithDot", "startwithdot")] + [InlineData("EndWithDot.", "endwithdot")] + [InlineData("My..Test__Project", "my--test--project")] + [InlineData("Project123.Test456", "project123-test456")] + [SkipOnHelix("Cert failure, https://github.com/dotnet/aspnetcore/issues/28090", Queues = "All.OSX;" + HelixConstants.Windows10Arm64 + HelixConstants.DebianArm64)] + public async Task BlazorWebTemplateLocalhostTld_GeneratesDnsCompliantHostnames(string projectName, string expectedHostname) + { + var project = await ProjectFactory.CreateProject(Output, projectName); + + await project.RunDotNetNewAsync("blazor", args: new[] { ArgConstants.LocalhostTld, ArgConstants.NoInteractivity }); + + var expectedLaunchProfileNames = new[] { "http", "https" }; + await project.VerifyLaunchSettings(expectedLaunchProfileNames); + await project.VerifyDnsCompliantHostname(expectedHostname); + } } diff --git a/src/ProjectTemplates/test/Templates.Tests/EmptyWebTemplateTest.cs b/src/ProjectTemplates/test/Templates.Tests/EmptyWebTemplateTest.cs index afab0feb7d21..222c22a51681 100644 --- a/src/ProjectTemplates/test/Templates.Tests/EmptyWebTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Tests/EmptyWebTemplateTest.cs @@ -1,6 +1,7 @@ // 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.Threading.Tasks; using Microsoft.AspNetCore.InternalTesting; using Templates.Test.Helpers; @@ -73,6 +74,24 @@ public async Task EmptyWebTemplateNoHttpsFSharp() await EmtpyTemplateCore("F#", args: new[] { ArgConstants.NoHttps }); } + [ConditionalTheory] + [InlineData("my.namespace.web", "my-namespace-web")] + [InlineData(".StartWithDot", "startwithdot")] + [InlineData("EndWithDot.", "endwithdot")] + [InlineData("My..Test__Project", "my--test--project")] + [InlineData("Project123.Test456", "project123-test456")] + [SkipOnHelix("Cert failure, https://github.com/dotnet/aspnetcore/issues/28090", Queues = "All.OSX;" + HelixConstants.Windows10Arm64 + HelixConstants.DebianArm64)] + public async Task EmptyWebTemplateLocalhostTld_GeneratesDnsCompliantHostnames(string projectName, string expectedHostname) + { + var project = await ProjectFactory.CreateProject(Output, projectName); + + await project.RunDotNetNewAsync("web", args: new[] { ArgConstants.LocalhostTld }); + + var expectedLaunchProfileNames = new[] { "http", "https" }; + await project.VerifyLaunchSettings(expectedLaunchProfileNames); + await project.VerifyDnsCompliantHostname(expectedHostname); + } + private async Task EmtpyTemplateCore(string languageOverride, string[] args = null) { var project = await ProjectFactory.CreateProject(Output); diff --git a/src/ProjectTemplates/test/Templates.Tests/ItemTemplateTests/BlazorServerTests.cs b/src/ProjectTemplates/test/Templates.Tests/ItemTemplateTests/BlazorServerTests.cs index c50e7e7f9a5a..c92a08a4d253 100644 --- a/src/ProjectTemplates/test/Templates.Tests/ItemTemplateTests/BlazorServerTests.cs +++ b/src/ProjectTemplates/test/Templates.Tests/ItemTemplateTests/BlazorServerTests.cs @@ -29,7 +29,7 @@ public async Task BlazorServerItemTemplate() { Project = await ProjectFactory.CreateProject(Output); - await Project.RunDotNetNewAsync("razorcomponent --name Different", isItemTemplate: true); + await Project.RunDotNetNewAsync("razorcomponent", isItemTemplate: true, args: ["--name", "Different"]); Project.AssertFileExists("Different.razor", shouldExist: true); Assert.Contains("

Different

", Project.ReadFile("Different.razor"));