Skip to content
Merged
1 change: 1 addition & 0 deletions src/ProjectTemplates/Shared/ArgConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
48 changes: 48 additions & 0 deletions src/ProjectTemplates/Shared/Project.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ internal async Task RunDotNetNewAsync(
// Used to set special options in MSBuild
IDictionary<string, string> 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<string, string>();
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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();
Expand Down
30 changes: 27 additions & 3 deletions src/ProjectTemplates/Shared/ProjectFactoryFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,22 @@ public async Task<Project> CreateProject(ITestOutputHelper output)
return project;
}

private Project CreateProjectImpl(ITestOutputHelper output)
public async Task<Project> 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
{
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
19 changes: 19 additions & 0 deletions src/ProjectTemplates/test/Templates.Tests/EmptyWebTemplateTest.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<h3>Different</h3>", Project.ReadFile("Different.razor"));
Expand Down
Loading