diff --git a/release_notes.md b/release_notes.md index ac9debcfc..20324c995 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,12 +1,10 @@ -# Azure Functions CLI 4.2.1 +# Azure Functions CLI 4.2.2 #### Host Version - Host Version: 4.1041.200 -- In-Proc Host Version: 4.41.100 (4.841.100, 4.641.100) +- In-Proc Host Version: 4.41.100 (4.841.100, 4.641.100) #### Changes -- Add support for .NET 10 isolated model (#4589) -- Update log streaming to support both connection string and instrumentation Key (#4586) -- Remove content of workers dir from minified versions (#4609) +- Fix .NET template install bug (#4612) diff --git a/src/Cli/func/Common/FileSystemHelpers.cs b/src/Cli/func/Common/FileSystemHelpers.cs index 5080db2a4..084187a46 100644 --- a/src/Cli/func/Common/FileSystemHelpers.cs +++ b/src/Cli/func/Common/FileSystemHelpers.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. using System.IO.Abstractions; @@ -127,6 +127,12 @@ public static string EnsureDirectory(string path) return path; } + public static bool EnsureDirectoryNotEmpty(string path) + { + return DirectoryExists(path) && + Instance.Directory.EnumerateFileSystemEntries(path).Any(); + } + public static void DeleteDirectorySafe(string path, bool ignoreErrors = true) { DeleteFileSystemInfo(Instance.DirectoryInfo.FromDirectoryName(path), ignoreErrors); diff --git a/src/Cli/func/Directory.Version.props b/src/Cli/func/Directory.Version.props index ad58cba1c..36635227a 100644 --- a/src/Cli/func/Directory.Version.props +++ b/src/Cli/func/Directory.Version.props @@ -1,7 +1,7 @@ - 4.2.1 + 4.2.2 true diff --git a/src/Cli/func/Helpers/DotnetHelpers.E2E.cs b/src/Cli/func/Helpers/DotnetHelpers.E2E.cs new file mode 100644 index 000000000..676076842 --- /dev/null +++ b/src/Cli/func/Helpers/DotnetHelpers.E2E.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Functions.Cli.Common; + +namespace Azure.Functions.Cli.Helpers +{ + // Partial class to hold E2E test related helpers + public static partial class DotnetHelpers + { + // Environment variable names to control custom hive usage in E2E tests + internal const string CustomHiveFlag = "FUNC_E2E_USE_CUSTOM_HIVE"; + internal const string CustomHiveRoot = "FUNC_E2E_HIVE_ROOT"; + internal const string CustomHiveKey = "FUNC_E2E_HIVE_KEY"; + + private static bool UseCustomTemplateHive() => string.Equals(Environment.GetEnvironmentVariable(CustomHiveFlag), "1", StringComparison.Ordinal); + + private static string GetHiveRoot() + { + string root = Environment.GetEnvironmentVariable(CustomHiveRoot); + + if (!string.IsNullOrWhiteSpace(root)) + { + return root; + } + + string coreToolsLocalDataPath = Utilities.EnsureCoreToolsLocalData(); + return Path.Combine(coreToolsLocalDataPath, "dotnet-templates-custom-hives"); + } + + // By default, each worker runtime shares a hive. This can be overridden by setting the FUNC_E2E_HIVE_KEY + // environment variable to a custom value, which will cause a separate hive to be used. + private static string GetHivePath(WorkerRuntime workerRuntime) + { + string key = Environment.GetEnvironmentVariable(CustomHiveKey); + string leaf = !string.IsNullOrWhiteSpace(key) ? key : $"{workerRuntime.ToString().ToLowerInvariant()}-hive"; + return Path.Combine(GetHiveRoot(), leaf); + } + + private static bool TryGetCustomHiveArg(WorkerRuntime workerRuntime, out string customHiveArg) + { + customHiveArg = string.Empty; + + if (!UseCustomTemplateHive()) + { + return false; + } + + string hive = GetHivePath(workerRuntime); + FileSystemHelpers.EnsureDirectory(hive); + + customHiveArg = $" --debug:custom-hive \"{hive}\""; + return true; + } + } +} diff --git a/src/Cli/func/Helpers/DotnetHelpers.cs b/src/Cli/func/Helpers/DotnetHelpers.cs index 6829a4206..217b69d0c 100644 --- a/src/Cli/func/Helpers/DotnetHelpers.cs +++ b/src/Cli/func/Helpers/DotnetHelpers.cs @@ -1,9 +1,9 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. -using System.Reflection; using System.Runtime.InteropServices; using System.Text; +using System.Text.RegularExpressions; using Azure.Functions.Cli.Common; using Colors.Net; using Microsoft.Azure.WebJobs.Extensions.Http; @@ -11,12 +11,21 @@ namespace Azure.Functions.Cli.Helpers { - public static class DotnetHelpers + public static partial class DotnetHelpers { - private const string WebJobsTemplateBasePackId = "Microsoft.Azure.WebJobs"; + private const string InProcTemplateBasePackId = "Microsoft.Azure.WebJobs"; private const string IsolatedTemplateBasePackId = "Microsoft.Azure.Functions.Worker"; - private const string TemplatesLockFileName = "func_dotnet_templates.lock"; - private static readonly Lazy>> _installedTemplatesList = new(GetInstalledTemplatePackageIds); + + /// + /// Gets or sets test hook to intercept 'dotnet new' invocations for unit tests. + /// If null, real process execution is used. + /// + internal static Func> RunDotnetNewFunc { get; set; } = null; + + private static Task RunDotnetNewAsync(string args) + => (RunDotnetNewFunc is not null) + ? RunDotnetNewFunc(args) + : new Executable("dotnet", args).RunAsync(); public static void EnsureDotnet() { @@ -64,7 +73,18 @@ public static async Task DetermineTargetFramework(string projectDirector throw new CliException($"Can not determine target framework for dotnet project at ${projectDirectory}"); } - return output.ToString(); + // Extract the target framework moniker (TFM) from the output using regex pattern matching + var outputString = output.ToString(); + + // Look for a line that looks like a target framework moniker + var tfm = TargetFrameworkHelper.TfmRegex.Match(outputString); + + if (!tfm.Success) + { + throw new CliException($"Could not parse target framework from output: {outputString}"); + } + + return tfm.Value; } public static async Task DeployDotnetProject(string name, bool force, WorkerRuntime workerRuntime, string targetFramework = "") @@ -78,7 +98,8 @@ await TemplateOperationAsync( var connectionString = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"--StorageConnectionStringValue \"{Constants.StorageEmulatorConnectionString}\"" : string.Empty; - var exe = new Executable("dotnet", $"new func {frameworkString} --AzureFunctionsVersion v4 --name {name} {connectionString} {(force ? "--force" : string.Empty)}"); + TryGetCustomHiveArg(workerRuntime, out string customHive); + var exe = new Executable("dotnet", $"new func {frameworkString} --AzureFunctionsVersion v4 --name {name} {connectionString} {(force ? "--force" : string.Empty)}{customHive}"); var exitCode = await exe.RunAsync(o => { }, e => ColoredConsole.Error.WriteLine(ErrorColor(e))); if (exitCode != 0) { @@ -109,7 +130,8 @@ await TemplateOperationAsync( } } - var exe = new Executable("dotnet", exeCommandArguments); + TryGetCustomHiveArg(workerRuntime, out string customHive); + var exe = new Executable("dotnet", exeCommandArguments + customHive); string dotnetNewErrorMessage = string.Empty; var exitCode = await exe.RunAsync(o => { }, e => { @@ -277,119 +299,110 @@ public static string GetCsprojOrFsproj() } } - private static async Task TemplateOperationAsync(Func action, WorkerRuntime workerRuntime) + internal static async Task TemplateOperationAsync(Func action, WorkerRuntime workerRuntime) { EnsureDotnet(); + // If we have enabled custom hives (for E2E tests), install templates there and run the action + if (UseCustomTemplateHive()) + { + await EnsureTemplatesInCustomHiveAsync(action, workerRuntime); + return; + } + + // Default CLI behaviour: Templates are installed globally, so we need to install/uninstall them around the action if (workerRuntime == WorkerRuntime.DotnetIsolated) { - await EnsureIsolatedTemplatesInstalled(); + await EnsureIsolatedTemplatesInstalledAsync(action); } else { - await EnsureWebJobsTemplatesInstalled(); + await EnsureInProcTemplatesInstalledAsync(action); } - - await action(); } - private static async Task EnsureIsolatedTemplatesInstalled() + private static async Task EnsureTemplatesInCustomHiveAsync(Func action, WorkerRuntime workerRuntime) { - if (AreDotnetTemplatePackagesInstalled(await _installedTemplatesList.Value, WebJobsTemplateBasePackId)) - { - await UninstallWebJobsTemplates(); - } - - if (AreDotnetTemplatePackagesInstalled(await _installedTemplatesList.Value, IsolatedTemplateBasePackId)) + // If the custom hive already has templates installed, just run the action and skip installation + string hivePackagesDir = Path.Combine(GetHivePath(workerRuntime), "packages"); + if (FileSystemHelpers.EnsureDirectoryNotEmpty(hivePackagesDir)) { + await action(); return; } - await FileLockHelper.WithFileLockAsync(TemplatesLockFileName, InstallIsolatedTemplates); + // Install only, no need to uninstall as we are using a custom hive + Func installTemplates = workerRuntime == WorkerRuntime.DotnetIsolated + ? InstallIsolatedTemplates + : InstallInProcTemplates; + + await installTemplates(); + await action(); } - private static async Task EnsureWebJobsTemplatesInstalled() + private static async Task EnsureIsolatedTemplatesInstalledAsync(Func action) { - if (AreDotnetTemplatePackagesInstalled(await _installedTemplatesList.Value, IsolatedTemplateBasePackId)) + try { - await UninstallIsolatedTemplates(); - } + // Uninstall any existing webjobs templates, as they conflict with isolated templates + await UninstallInProcTemplates(); - if (AreDotnetTemplatePackagesInstalled(await _installedTemplatesList.Value, WebJobsTemplateBasePackId)) + // Install the latest isolated templates + await InstallIsolatedTemplates(); + await action(); + } + finally { - return; + await UninstallIsolatedTemplates(); } - - await FileLockHelper.WithFileLockAsync(TemplatesLockFileName, InstallWebJobsTemplates); - } - - internal static bool AreDotnetTemplatePackagesInstalled(HashSet templates, string packageIdPrefix) - { - var hasProjectTemplates = templates.Contains($"{packageIdPrefix}.ProjectTemplates", StringComparer.OrdinalIgnoreCase); - var hasItemTemplates = templates.Contains($"{packageIdPrefix}.ItemTemplates", StringComparer.OrdinalIgnoreCase); - - return hasProjectTemplates && hasItemTemplates; } - private static async Task> GetInstalledTemplatePackageIds() + private static async Task EnsureInProcTemplatesInstalledAsync(Func action) { - var exe = new Executable("dotnet", "new uninstall", shareConsole: false); - var output = new StringBuilder(); - var exitCode = await exe.RunAsync(o => output.AppendLine(o), e => output.AppendLine(e)); + try + { + // Uninstall any existing isolated templates, as they conflict with webjobs templates + await UninstallIsolatedTemplates(); - if (exitCode != 0) + // Install the latest webjobs templates + await InstallInProcTemplates(); + await action(); + } + finally { - throw new CliException("Failed to get list of installed template packages"); + await UninstallInProcTemplates(); } + } - var lines = output.ToString() - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - - var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase); - - const string uninstallPrefix = "dotnet new uninstall "; + private static string[] GetNupkgFiles(string templatesPath) + { + var templatesLocation = Path.Combine( + Path.GetDirectoryName(AppContext.BaseDirectory), + Path.Combine(templatesPath)); - foreach (var line in lines) + if (!FileSystemHelpers.DirectoryExists(templatesLocation)) { - var trimmed = line.Trim(); - - if (trimmed.StartsWith(uninstallPrefix, StringComparison.OrdinalIgnoreCase)) - { - var packageId = trimmed.Substring(uninstallPrefix.Length).Trim(); - if (!string.IsNullOrWhiteSpace(packageId)) - { - packageIds.Add(packageId); - } - } + throw new CliException($"Can't find templates location. Looked under '{templatesLocation}'"); } - return packageIds; + return Directory.GetFiles(templatesLocation, "*.nupkg", SearchOption.TopDirectoryOnly); } - private static Task UninstallIsolatedTemplates() => DotnetTemplatesAction("uninstall", nugetPackageList: [$"{IsolatedTemplateBasePackId}.ProjectTemplates", $"{IsolatedTemplateBasePackId}.ItemTemplates"]); + private static Task InstallIsolatedTemplates() => DotnetTemplatesAction("install", WorkerRuntime.DotnetIsolated, Path.Combine("templates", $"net-isolated")); - private static Task UninstallWebJobsTemplates() => DotnetTemplatesAction("uninstall", nugetPackageList: [$"{WebJobsTemplateBasePackId}.ProjectTemplates", $"{WebJobsTemplateBasePackId}.ItemTemplates"]); + private static Task UninstallIsolatedTemplates() => DotnetTemplatesAction("uninstall", WorkerRuntime.DotnetIsolated, nugetPackageList: [$"{IsolatedTemplateBasePackId}.ProjectTemplates", $"{IsolatedTemplateBasePackId}.ItemTemplates"]); - private static Task InstallWebJobsTemplates() => DotnetTemplatesAction("install", "templates"); + private static Task InstallInProcTemplates() => DotnetTemplatesAction("install", WorkerRuntime.Dotnet, "templates"); - private static Task InstallIsolatedTemplates() => DotnetTemplatesAction("install", Path.Combine("templates", $"net-isolated")); + private static Task UninstallInProcTemplates() => DotnetTemplatesAction("uninstall", WorkerRuntime.Dotnet, nugetPackageList: [$"{InProcTemplateBasePackId}.ProjectTemplates", $"{InProcTemplateBasePackId}.ItemTemplates"]); - private static async Task DotnetTemplatesAction(string action, string templateDirectory = null, string[] nugetPackageList = null) + private static async Task DotnetTemplatesAction(string action, WorkerRuntime workerRuntime, string templateDirectory = null, string[] nugetPackageList = null) { string[] list; if (!string.IsNullOrEmpty(templateDirectory)) { - var templatesLocation = Path.Combine( - Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), - templateDirectory); - - if (!FileSystemHelpers.DirectoryExists(templatesLocation)) - { - throw new CliException($"Can't find templates location. Looked under '{templatesLocation}'"); - } - - list = Directory.GetFiles(templatesLocation, "*.nupkg", SearchOption.TopDirectoryOnly); + list = GetNupkgFiles(templateDirectory); } else { @@ -398,8 +411,9 @@ private static async Task DotnetTemplatesAction(string action, string templateDi foreach (var nupkg in list) { - var exe = new Executable("dotnet", $"new {action} \"{nupkg}\""); - await exe.RunAsync(); + TryGetCustomHiveArg(workerRuntime, out string customHive); + var args = $"new {action} \"{nupkg}\" {customHive}"; + await RunDotnetNewAsync(args); } } } diff --git a/src/Cli/func/Helpers/TargetFrameworkHelper.cs b/src/Cli/func/Helpers/TargetFrameworkHelper.cs index 742be6836..ebb43bf6f 100644 --- a/src/Cli/func/Helpers/TargetFrameworkHelper.cs +++ b/src/Cli/func/Helpers/TargetFrameworkHelper.cs @@ -1,12 +1,66 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +using System.Text.RegularExpressions; using Azure.Functions.Cli.Common; namespace Azure.Functions.Cli.Helpers { public static class TargetFrameworkHelper { + // Regex sub-patterns for .NET Target Framework Monikers (TFMs) + // Modern .NET (e.g., net6.0, net7.0-windows) + private const string ModernNetPattern = @"net\d+\.\d+(?:-[a-z][a-z0-9]*(?:\d+(?:\.\d+)*)?)?"; + + // netstandard and netcoreapp (e.g., netstandard2.0, netcoreapp3.1) + private const string NetStandardCoreAppPattern = @"net(?:standard|coreapp)\d+(?:\.\d+)?"; + + // Classic .NET Framework versions (e.g., net45, net48) + private const string ClassicNetFrameworkPattern = @"net(?:10|11|20|35|40|403|45|451|452|46|461|462|47|471|472|48|481)"; + + // Universal Windows Platform (UAP) (e.g., uap10.0) + private const string UapPattern = @"uap\d+(?:\.\d+)*"; + + // Windows Phone and Windows Phone App (e.g., wp8, wpa81) + private const string WindowsPhonePattern = @"(?:wp(?:7|75|8|81)|wpa81)"; + + // Silverlight (e.g., sl4, sl5) + private const string SilverlightPattern = @"sl(?:4|5)"; + + // Tizen (e.g., tizen4.0) + private const string TizenPattern = @"tizen\d+(?:\.\d+)?"; + + // NetNano (e.g., netnano1.0) + private const string NetNanoPattern = @"netnano\d+(?:\.\d+)?"; + + // .NET Micro Framework + private const string NetMfPattern = @"netmf"; + + // Legacy WinStore aliases (e.g., win8, win81, win10, netcore45, netcore50) + private const string LegacyWinStorePattern = @"(?:win(?:8|81|10)|netcore(?:45|451|50)|netcore)"; + + /// + /// Regex that matches all valid .NET Target Framework Monikers (TFMs). + /// Covers modern TFMs (netX.Y[-osversion]), + /// netstandard, netcoreapp, classic .NET Framework, UAP, WP, Silverlight, + /// Tizen, NetNano, NetMF, and legacy WinStore aliases. + /// + public static readonly Regex TfmRegex = new Regex( + string.Join("|", new[] + { + ModernNetPattern, + NetStandardCoreAppPattern, + ClassicNetFrameworkPattern, + UapPattern, + WindowsPhonePattern, + SilverlightPattern, + TizenPattern, + NetNanoPattern, + NetMfPattern, + LegacyWinStorePattern + }), + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly IEnumerable _supportedTargetFrameworks = [TargetFramework.Net10, TargetFramework.Net9, TargetFramework.Net8, TargetFramework.Net7, TargetFramework.Net6, TargetFramework.Net48]; private static readonly IEnumerable _supportedInProcTargetFrameworks = [TargetFramework.Net8, TargetFramework.Net6]; diff --git a/test/Cli/Func.E2ETests/BaseE2ETests.cs b/test/Cli/Func.E2ETests/BaseE2ETests.cs index c43851de4..90f7c8e3e 100644 --- a/test/Cli/Func.E2ETests/BaseE2ETests.cs +++ b/test/Cli/Func.E2ETests/BaseE2ETests.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System.Runtime.InteropServices; -using Azure.Functions.Cli.Abstractions; +using Azure.Functions.Cli.Helpers; using Azure.Functions.Cli.TestFramework.Helpers; using Xunit; using Xunit.Abstractions; @@ -37,6 +37,11 @@ public Task InitializeAsync() } } + var hiveRoot = Path.Combine(Path.GetTempPath(), "func-e2e-hives"); + Environment.SetEnvironmentVariable(DotnetHelpers.CustomHiveFlag, "1"); + Environment.SetEnvironmentVariable(DotnetHelpers.CustomHiveRoot, hiveRoot); + Directory.CreateDirectory(hiveRoot); + Directory.CreateDirectory(WorkingDirectory); return Task.CompletedTask; } diff --git a/test/Cli/Func.E2ETests/Fixtures/DotnetIsolatedFunctionAppFixture.cs b/test/Cli/Func.E2ETests/Fixtures/DotnetIsolatedFunctionAppFixture.cs index e8215dbce..0ebb66046 100644 --- a/test/Cli/Func.E2ETests/Fixtures/DotnetIsolatedFunctionAppFixture.cs +++ b/test/Cli/Func.E2ETests/Fixtures/DotnetIsolatedFunctionAppFixture.cs @@ -10,6 +10,10 @@ public class DotnetIsolatedFunctionAppFixture : BaseFunctionAppFixture public DotnetIsolatedFunctionAppFixture() : base(WorkerRuntime.DotnetIsolated) { + var hiveRoot = Path.Combine(Path.GetTempPath(), "func-e2e-hives"); + Environment.SetEnvironmentVariable(DotnetHelpers.CustomHiveFlag, "1"); + Environment.SetEnvironmentVariable(DotnetHelpers.CustomHiveRoot, hiveRoot); + Directory.CreateDirectory(hiveRoot); } } } diff --git a/test/Cli/Func.E2ETests/Traits/TestTraits.cs b/test/Cli/Func.E2ETests/Traits/TestTraits.cs index 6b862ec86..56551275e 100644 --- a/test/Cli/Func.E2ETests/Traits/TestTraits.cs +++ b/test/Cli/Func.E2ETests/Traits/TestTraits.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. namespace Azure.Functions.Cli.E2ETests.Traits @@ -33,11 +33,5 @@ internal static class TestTraits /// This is done since when the dotnet isolated tests are run with dotnet inproc, we run into templating conflict errors. /// public const string InProc = "InProc"; - - public const string WorkerRuntime = "WorkerRuntime"; - public const string Dotnet = "Dotnet"; - public const string DotnetIsolated = "DotnetIsolated"; - public const string Node = "Node"; - public const string Powershell = "Powershell"; } } diff --git a/test/Cli/Func.UnitTests/HelperTests/DotnetHelpersTests.cs b/test/Cli/Func.UnitTests/HelperTests/DotnetHelpersTests.cs index cf11da7ca..7f7a3e282 100644 --- a/test/Cli/Func.UnitTests/HelperTests/DotnetHelpersTests.cs +++ b/test/Cli/Func.UnitTests/HelperTests/DotnetHelpersTests.cs @@ -36,47 +36,55 @@ public void GetTemplates_ReturnsExpectedTemplates(WorkerRuntime runtime, int exp } [Theory] - [InlineData("Microsoft.Azure.Functions.Worker")] - [InlineData("Microsoft.Azure.WebJobs")] - public void AreDotnetTemplatePackagesInstalled_ReturnsTrue_WhenTemplatesExists(string pkgPrefix) + [InlineData(WorkerRuntime.Dotnet, "")] + [InlineData(WorkerRuntime.DotnetIsolated, "net-isolated")] + public async Task TemplateOperationAsync_Isolated_InstallsAndUninstalls_InOrder(WorkerRuntime workerRuntime, string path) { // Arrange - var templates = new HashSet { $"{pkgPrefix}.ProjectTemplates", $"{pkgPrefix}.ItemTemplates" }; + var calls = new List(); + var original = DotnetHelpers.RunDotnetNewFunc; + try + { + DotnetHelpers.RunDotnetNewFunc = args => + { + calls.Add(args); + return Task.FromResult(0); + }; - // Act - var result = DotnetHelpers.AreDotnetTemplatePackagesInstalled(templates, pkgPrefix); + bool actionCalled = false; + Func action = () => + { + actionCalled = true; + return Task.CompletedTask; + }; - // Assert - Assert.True(result); - } + // Act + await DotnetHelpers.TemplateOperationAsync(action, workerRuntime); - [Theory] - [InlineData("ProjectTemplates")] - [InlineData("ItemTemplates")] - public void AreDotnetTemplatePackagesInstalled_ReturnsFalse_WhenOnlyOneRequiredTemplateExists(string pkgSuffix) - { - // Arrange - var templates = new HashSet { $"Microsoft.Azure.Functions.Worker.{pkgSuffix}" }; + // Assert + Assert.True(actionCalled); + var uninstallCalls = calls.Where(a => a.Contains("new uninstall", StringComparison.OrdinalIgnoreCase)).ToList(); + Assert.True(uninstallCalls.Count >= 4, $"Expected at least 4 uninstall calls, got {uninstallCalls.Count}"); - // Act - var result = DotnetHelpers.AreDotnetTemplatePackagesInstalled(templates, "Microsoft.Azure.Functions.Worker"); + // Check for at least 2 install calls with correct template path + var installCalls = calls.Where(a => a.Contains("new install", StringComparison.OrdinalIgnoreCase) && + a.Contains(Path.Combine("templates", path), StringComparison.OrdinalIgnoreCase)).ToList(); + Assert.True(installCalls.Count >= 2, $"Expected at least 2 install calls with '{Path.Combine("templates", path)}', got {installCalls.Count}"); - // Assert - Assert.False(result); - } + // Verify the sequence: first 2 should be uninstalls + Assert.Contains("new uninstall", calls[0], StringComparison.OrdinalIgnoreCase); + Assert.Contains("new uninstall", calls[1], StringComparison.OrdinalIgnoreCase); - [Fact] - public void AreDotnetTemplatePackagesInstalled_ReturnsFalse_WhenTemplatesDoesNotExist() - { - // Arrange - var templates = new HashSet { "OtherCompany.ProjectTemplates", "OtherCompany.ItemTemplates", "Microsoft.Azure.Functions.Worker" }; - - // Act - // Should fail as we are looking for Item and Project templates - var result = DotnetHelpers.AreDotnetTemplatePackagesInstalled(templates, "Microsoft.Azure.Functions.Worker"); - - // Assert - Assert.False(result); + // Find the last 2 calls and verify they are uninstalls + var lastTwoCalls = calls.TakeLast(2).ToList(); + Assert.True( + lastTwoCalls.All(call => call.Contains("new uninstall", StringComparison.OrdinalIgnoreCase)), + "Last 2 calls should be uninstall operations"); + } + finally + { + DotnetHelpers.RunDotnetNewFunc = original; + } } } } diff --git a/test/Cli/TestFramework/Assertions/CommandResultAssertions.cs b/test/Cli/TestFramework/Assertions/CommandResultAssertions.cs index b0de2ae2c..88902f5de 100644 --- a/test/Cli/TestFramework/Assertions/CommandResultAssertions.cs +++ b/test/Cli/TestFramework/Assertions/CommandResultAssertions.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. // Based off of: https://github.com/dotnet/sdk/blob/e793aa4709d28cd783712df40413448250e26fea/test/Microsoft.NET.TestFramework/Assertions/CommandResultAssertions.cs @@ -124,7 +124,7 @@ public AndConstraint FilesExistsWithExpectContent(List< foreach (var expectedContent in file.ExpectedContents) { Execute.Assertion.ForCondition(actualContent.Contains(expectedContent)) - .FailWith($"File '{file.FilePath}' should contain '{expectedContent}', but it did not."); + .FailWith($"File '{file.FilePath}' should contain '{expectedContent}', but it did not. Actual content is: {actualContent}"); } }