diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json index caeceae1dde..d95f7c41c13 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -499,6 +499,12 @@ "valueTransform": "vectorStoreIndexNameTransform", "replaces": "data-ChatWithCustomData-CSharp.Web-" }, + "aspireClassNameReplacer": { + "type": "derived", + "valueSource": "name", + "valueTransform": "aspireClassName_ReplaceInvalidChars", + "replaces": "ChatWithCustomData_CSharp_Web_AspireClassName" + }, "webProjectNamespaceAdjuster": { "type": "generated", "generator": "switch", @@ -524,6 +530,12 @@ } }, "forms": { + "aspireClassName_ReplaceInvalidChars": { + "identifier": "replace", + "pattern": "(((?<=\\.)|^)(?=\\d)|\\W)", + "replacement": "_", + "description": "Insert underscore before digits at start, or after a dot, or to replace non-word characters" + }, "vectorStoreIndexNameTransform": { "identifier": "chain", "steps": [ diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs index c3045240cda..df2f6765d9e 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs @@ -51,7 +51,7 @@ #else // UseLocalVectorStore #endif -var webApp = builder.AddProject("aichatweb-app"); +var webApp = builder.AddProject("aichatweb-app"); #if (IsOllama) // AI SERVICE PROVIDER REFERENCES webApp .WithReference(chat) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index 1b8c4177f40..f5d2bc52e3a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.TestUtilities; using Xunit; using Xunit.Abstractions; @@ -71,6 +72,44 @@ public async Task CreateRestoreAndBuild_AspireTemplate(params string[] args) await Fixture.BuildProjectAsync(project); } + /// + /// Runs a single test with --aspire true and a project name that will trigger the class + /// name normalization bug reported in https://github.com/dotnet/extensions/issues/6811. + /// + [Fact] + public async Task CreateRestoreAndBuild_AspireProjectName() + { + await CreateRestoreAndBuild_AspireProjectName_Variants("azureopenai", "mix.ed-dash_name 123"); + } + + /// + /// Tests build for various project name formats, including dots and other + /// separators, to trigger the class name normalization bug described + /// in https://github.com/dotnet/extensions/issues/6811 + /// This runs for all provider combinations with --aspire true and different + /// project names to ensure the bug is caught in all scenarios. + /// + /// + /// Because this test takes a long time to run, it is skipped by default. Set the + /// environment variable AI_TEMPLATES_TEST_PROJECT_NAMES to "true" or "1" + /// to enable it. + /// + [ConditionalTheory] + [EnvironmentVariableCondition("AI_TEMPLATES_TEST_PROJECT_NAMES", "true", "1")] + [MemberData(nameof(GetAspireProjectNameVariants))] + public async Task CreateRestoreAndBuild_AspireProjectName_Variants(string provider, string projectName) + { + var project = await Fixture.CreateProjectAsync( + templateName: "aichatweb", + projectName: projectName, + args: new[] { "--aspire", $"--provider={provider}" }); + + project.StartupProjectRelativePath = $"{projectName}.AppHost"; + + await Fixture.RestoreProjectAsync(project); + await Fixture.BuildProjectAsync(project); + } + private static readonly (string name, string[] values)[] _templateOptions = [ ("--provider", ["azureopenai", "githubmodels", "ollama", "openai"]), ("--vector-store", ["azureaisearch", "local", "qdrant"]), @@ -158,4 +197,26 @@ private static IEnumerable GetAllPossibleOptions(ReadOnlyMemory<(strin } } } + + public static IEnumerable GetAspireProjectNameVariants() + { + foreach (string provider in new[] { "ollama", "openai", "azureopenai", "githubmodels" }) + { + foreach (string projectName in new[] + { + "mix.ed-dash_name 123", + "dot.name", + "project.123", + "space name", + ".1My.Projec-", + "1Project123", + "11double", + "1", + "nomatch" + }) + { + yield return new object[] { provider, projectName }; + } + } + } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj index d2fc26ea0ab..e4c52714b79 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs b/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs new file mode 100644 index 00000000000..45a54409047 --- /dev/null +++ b/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; + +namespace Microsoft.TestUtilities; + +/// +/// Skips a test based on the value of an environment variable. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] +public class EnvironmentVariableConditionAttribute : Attribute, ITestCondition +{ + private string? _currentValue; + + /// + /// Initializes a new instance of the class. + /// + /// Name of the environment variable. + /// Value(s) of the environment variable to match for the condition. + /// + /// By default, the test will be run if the value of the variable matches any of the supplied values. + /// Set to False to run the test only if the value does not match. + /// + public EnvironmentVariableConditionAttribute(string variableName, params string[] values) + { + if (string.IsNullOrEmpty(variableName)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(variableName)); + } + + if (values == null || values.Length == 0) + { + throw new ArgumentException("You must supply at least one value to match.", nameof(values)); + } + + VariableName = variableName; + Values = values; + } + + /// + /// Gets or sets a value indicating whether the test should run if the value of the variable matches any + /// of the supplied values. If False, the test runs only if the value does not match any of the + /// supplied values. Default is True. + /// + public bool RunOnMatch { get; set; } = true; + + /// + /// Gets the name of the environment variable. + /// + public string VariableName { get; } + + /// + /// Gets the value(s) of the environment variable to match for the condition. + /// + public string[] Values { get; } + + /// + /// Gets a value indicating whether the condition is met for the configured environment variable and values. + /// + public bool IsMet + { + get + { + _currentValue ??= Environment.GetEnvironmentVariable(VariableName); + var hasMatched = Values.Any(value => string.Equals(value, _currentValue, StringComparison.OrdinalIgnoreCase)); + + return RunOnMatch ? hasMatched : !hasMatched; + } + } + + /// + /// Gets a value indicating the reason the test was skipped. + /// + public string SkipReason + { + get + { + var value = _currentValue ?? "(null)"; + + return $"Test skipped on environment variable with name '{VariableName}' and value '{value}' " + + $"for the '{nameof(RunOnMatch)}' value of '{RunOnMatch}'."; + } + } +}