diff --git a/Directory.Build.props b/Directory.Build.props index a3764f9815..7067126bf4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,6 +2,14 @@ + + + $(MicrosoftExtensionsPackageVersion10) + $(MicrosoftExtensionsPackageVersion10) + $(MicrosoftExtensionsPackageVersion10) + $(MicrosoftExtensionsPackageVersion10) + + $(CopyrightMicrosoft) MIT diff --git a/eng/Versions.props b/eng/Versions.props index 42b38cdacd..0c92cbb2ef 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -45,6 +45,7 @@ 11.0.0-rc.2.26455.110 11.0.0-preview.1.26104.118 + 10.0.12 11.0.0-rc.2.26455.110 diff --git a/src/dotnet-scaffolding/dotnet-scaffold/Interactive/Flow/Steps/CategoryDiscovery.cs b/src/dotnet-scaffolding/dotnet-scaffold/Interactive/Flow/Steps/CategoryDiscovery.cs index c585838c17..c6ddf356a7 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/Interactive/Flow/Steps/CategoryDiscovery.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/Interactive/Flow/Steps/CategoryDiscovery.cs @@ -45,7 +45,8 @@ public CategoryDiscovery(IDotNetToolService dotnetToolService, DotNetToolInfo? c .WithSpinner() .Start("Discovering scaffolders", statusContext => { - return _dotnetToolService.GetAllCommandsParallel(envVars: envVars); + IList? components = _componentPicked is null ? null : [_componentPicked]; + return _dotnetToolService.GetAllCommandsParallel(components, envVars); }); if (allCommands is not null) diff --git a/src/dotnet-scaffolding/dotnet-scaffold/Interactive/Flow/Steps/CommandDiscovery.cs b/src/dotnet-scaffolding/dotnet-scaffold/Interactive/Flow/Steps/CommandDiscovery.cs index 328cfa058d..8356fc806b 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/Interactive/Flow/Steps/CommandDiscovery.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/Interactive/Flow/Steps/CommandDiscovery.cs @@ -49,7 +49,8 @@ public CommandDiscovery(IDotNetToolService dotnetToolService, DotNetToolInfo? co .WithSpinner() .Start("Discovering scaffolders", statusContext => { - return _dotnetToolService.GetAllCommandsParallel(envVars: envVars); + IList? components = _componentPicked is null ? null : [_componentPicked]; + return _dotnetToolService.GetAllCommandsParallel(components, envVars); }); if (allCommands is not null) diff --git a/src/dotnet-scaffolding/dotnet-scaffold/Services/DotNetToolService.cs b/src/dotnet-scaffolding/dotnet-scaffold/Services/DotNetToolService.cs index 1e3255dd28..f6d3df7904 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/Services/DotNetToolService.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/Services/DotNetToolService.cs @@ -16,6 +16,8 @@ namespace Microsoft.DotNet.Tools.Scaffold.Services; /// internal class DotNetToolService : IDotNetToolService { + private const string DotNetScaffoldPackageName = "Microsoft.dotnet-scaffold"; + private readonly ILogger _logger; private readonly IEnvironmentService _environmentService; private readonly IFileSystem _fileSystem; @@ -39,6 +41,7 @@ public DotNetToolService(ILogger logger, IEnvironmentService /// /// Gets the list of commands provided by a specific .NET tool. + /// Built-in commands are queried from the running assembly. /// /// The .NET tool information. /// Optional environment variables. @@ -46,11 +49,20 @@ public DotNetToolService(ILogger logger, IEnvironmentService public List GetCommands(DotNetToolInfo dotnetTool, IDictionary? envVars = null) { List? commands = null; - var runner = dotnetTool.IsGlobalTool ? - DotnetCliRunner.Create(dotnetTool.Command, ["get-commands"], envVars) : - DotnetCliRunner.CreateDotNet(dotnetTool.Command, ["get-commands"], envVars); + DotnetCliRunner runner; + if (IsDotNetScaffoldTool(dotnetTool)) + { + // Preserve support for tools installed with --allow-roll-forward. + runner = DotnetCliRunner.CreateDotNet("exec", ["--roll-forward", "Major", typeof(DotNetToolService).Assembly.Location, "get-commands"], envVars); + } + else + { + runner = dotnetTool.IsGlobalTool ? + DotnetCliRunner.Create(dotnetTool.Command, ["get-commands"], envVars) : + DotnetCliRunner.CreateDotNet(dotnetTool.Command, ["get-commands"], envVars); + } - var exitCode = runner.ExecuteAndCaptureOutput(out var stdOut, out _); + var exitCode = ExecuteAndCaptureOutput(runner, out var stdOut, out _); if (exitCode == 0 && !string.IsNullOrEmpty(stdOut)) { try @@ -95,25 +107,27 @@ public List GetCommands(DotNetToolInfo dotnetTool, IDictionary - /// Gets all commands from all .NET tools in parallel. + /// Gets all commands from the specified .NET tools in parallel. /// - /// Optional list of components to query. If null, all tools are queried. + /// Optional list of components to query. If null or empty, the dotnet-scaffold tool is queried. /// Optional environment variables. /// List of key-value pairs of tool command and . public IList> GetAllCommandsParallel(IList? components = null, IDictionary? envVars = null) { + var restoreLocalTools = components is { Count: > 0 }; if (components is null || components.Count == 0) { - components = GetDotNetTools(refresh: true, envVars); + components = GetDotNetTools(refresh: true, envVars) + .Where(IsDotNetScaffoldTool) + .ToList(); } - //if any local tools are present, we need to restore them first - //when sdks/runtimes are switched/rolled forward, local tools need to be restored before they are called - var anyLocalTools = components.FirstOrDefault(x => !x.IsGlobalTool) is not null; - if (anyLocalTools) + // Explicitly supplied local tools may need to be restored when SDKs or runtimes change. + // Default discovery queries the running assembly, so the discovered installation need not be restored. + if (restoreLocalTools && components.Any(x => !x.IsGlobalTool)) { var runner = DotnetCliRunner.CreateDotNet("tool", ["restore"], envVars); - runner.ExecuteAndCaptureOutput(out _, out _); + ExecuteAndCaptureOutput(runner, out _, out _); } var options = new ParallelOptions @@ -137,6 +151,15 @@ public IList> GetAllCommandsParallel(IList + /// Executes a tool discovery command and captures its output. + /// + protected virtual int ExecuteAndCaptureOutput(DotnetCliRunner runner, out string? stdOut, out string? stdErr) + => runner.ExecuteAndCaptureOutput(out stdOut, out stdErr); + + private static bool IsDotNetScaffoldTool(DotNetToolInfo tool) + => tool.PackageName.Equals(DotNetScaffoldPackageName, StringComparison.OrdinalIgnoreCase); + /// /// Installs a .NET tool using the dotnet CLI. /// @@ -233,8 +256,8 @@ public IList GetDotNetTools(bool refresh = false, IDictionary(); var runner = DotnetCliRunner.CreateDotNet("tool", ["list", "-g"], envVars); var localRunner = DotnetCliRunner.CreateDotNet("tool", ["list"], envVars); - var exitCode = runner.ExecuteAndCaptureOutput(out var stdOut, out _); - var localExitCode = localRunner.ExecuteAndCaptureOutput(out var localStdOut, out var localStdErr); + var exitCode = ExecuteAndCaptureOutput(runner, out var stdOut, out _); + var localExitCode = ExecuteAndCaptureOutput(localRunner, out var localStdOut, out var localStdErr); // Parse through local dotnet tools first. if (localExitCode == 0 && !string.IsNullOrEmpty(localStdOut)) { diff --git a/src/dotnet-scaffolding/dotnet-scaffold/Services/IDotNetToolService.cs b/src/dotnet-scaffolding/dotnet-scaffold/Services/IDotNetToolService.cs index 3f26d9c830..3d539d8796 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/Services/IDotNetToolService.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/Services/IDotNetToolService.cs @@ -13,7 +13,7 @@ internal interface IDotNetToolService /// /// Gets all commands for the specified tools in parallel. /// - /// The list of tool components to query. If null, all tools are used. + /// The list of tool components to query. If null or empty, the dotnet-scaffold tool is used. /// Optional environment variables for the command execution. /// A list of key-value pairs mapping tool command names to their command info. IList> GetAllCommandsParallel(IList? components = null, IDictionary? envVars = null); @@ -56,6 +56,7 @@ internal interface IDotNetToolService /// /// Gets the list of commands provided by a specific .NET tool. + /// Built-in commands are queried from the running assembly. /// /// The tool to query for commands. /// Optional environment variables for the command execution. diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/API/ApiControllerNet9IntegrationTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/API/ApiControllerNet9IntegrationTests.cs index 68d608cd5a..d9c49b80b6 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/API/ApiControllerNet9IntegrationTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/API/ApiControllerNet9IntegrationTests.cs @@ -5,10 +5,11 @@ using System.Threading.Tasks; using Microsoft.DotNet.Tools.Scaffold.Tests.Helpers; using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.Integration.API; -public class ApiControllerNet9IntegrationTests : ApiControllerIntegrationTestsBase +public class ApiControllerNet9IntegrationTests(ITestOutputHelper output) : ApiControllerIntegrationTestsBase { protected override string TargetFramework => "net9.0"; protected override string TestClassName => nameof(ApiControllerNet9IntegrationTests); @@ -36,6 +37,7 @@ public async Task Scaffold_ApiControllerCrud_Net9_CliInvocation() "--controller", "TestApiController", "--dataContext", "TestDbContext", "--dbProvider", "sqlite-efcore"); + output.WriteLine($"CLI exit code: {cliExitCode}\nStandard output:\n{cliOutput}\nStandard error:\n{cliError}"); Assert.True(cliExitCode == 0, $"CLI scaffold should succeed.\nOutput: {cliOutput}\nError: {cliError}"); // Assert — expected files were created diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/API/MinimalApiNet9IntegrationTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/API/MinimalApiNet9IntegrationTests.cs index 04644139ba..6b647303ec 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/API/MinimalApiNet9IntegrationTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/API/MinimalApiNet9IntegrationTests.cs @@ -5,10 +5,11 @@ using System.Threading.Tasks; using Microsoft.DotNet.Tools.Scaffold.Tests.Helpers; using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.Integration.API; -public class MinimalApiNet9IntegrationTests : MinimalApiIntegrationTestsBase +public class MinimalApiNet9IntegrationTests(ITestOutputHelper output) : MinimalApiIntegrationTestsBase { protected override string TargetFramework => "net9.0"; protected override string TestClassName => nameof(MinimalApiNet9IntegrationTests); @@ -36,6 +37,7 @@ public async Task Scaffold_MinimalApi_Net9_CliInvocation() "--endpoints", "TestModelEndpoints", "--dataContext", "TestDbContext", "--dbProvider", "sqlite-efcore"); + output.WriteLine($"CLI exit code: {cliExitCode}\nStandard output:\n{cliOutput}\nStandard error:\n{cliError}"); Assert.True(cliExitCode == 0, $"CLI scaffold should succeed.\nOutput: {cliOutput}\nError: {cliError}"); // Assert — expected files were created diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet9IntegrationTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet9IntegrationTests.cs index 3345320c79..10529fa6d6 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet9IntegrationTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet9IntegrationTests.cs @@ -6,10 +6,11 @@ using System.Threading.Tasks; using Microsoft.DotNet.Tools.Scaffold.Tests.Helpers; using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.Integration.Identity; -public class IdentityNet9IntegrationTests : IdentityIntegrationTestsBase +public class IdentityNet9IntegrationTests(ITestOutputHelper output) : IdentityIntegrationTestsBase { protected override string TargetFramework => "net9.0"; protected override string TestClassName => nameof(IdentityNet9IntegrationTests); @@ -78,6 +79,7 @@ public async Task Scaffold_Identity_Net9_CliInvocation() "--project", _testProjectPath, "--dataContext", "TestDbContext", "--dbProvider", "sqlite-efcore"); + output.WriteLine($"CLI exit code: {cliExitCode}\nStandard output:\n{cliOutput}\nStandard error:\n{cliError}"); Assert.True(cliExitCode == 0, $"CLI scaffold should succeed.\nOutput: {cliOutput}\nError: {cliError}"); // Assert — expected files/directories were created diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/RazorPages/RazorPagesCrudNet9IntegrationTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/RazorPages/RazorPagesCrudNet9IntegrationTests.cs index 7c04f18b70..776188d643 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/RazorPages/RazorPagesCrudNet9IntegrationTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/RazorPages/RazorPagesCrudNet9IntegrationTests.cs @@ -6,10 +6,11 @@ using System.Threading.Tasks; using Microsoft.DotNet.Tools.Scaffold.Tests.Helpers; using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.Integration.RazorPages; -public class RazorPagesCrudNet9IntegrationTests : RazorPagesCrudIntegrationTestsBase +public class RazorPagesCrudNet9IntegrationTests(ITestOutputHelper output) : RazorPagesCrudIntegrationTestsBase { protected override string TargetFramework => "net9.0"; protected override string TestClassName => nameof(RazorPagesCrudNet9IntegrationTests); @@ -67,6 +68,7 @@ public async Task Scaffold_RazorPagesCrud_Net9_CliInvocation() "--dataContext", "TestDbContext", "--dbProvider", "sqlite-efcore", "--page", "CRUD"); + output.WriteLine($"CLI exit code: {cliExitCode}\nStandard output:\n{cliOutput}\nStandard error:\n{cliError}"); Assert.True(cliExitCode == 0, $"CLI scaffold should succeed.\nOutput: {cliOutput}\nError: {cliError}"); // Assert — expected files were created (skip if scaffolding encountered errors) diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/Services/DotNetToolServiceTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/Services/DotNetToolServiceTests.cs new file mode 100644 index 0000000000..a000331349 --- /dev/null +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/Services/DotNetToolServiceTests.cs @@ -0,0 +1,153 @@ +// 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.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using Microsoft.DotNet.Scaffolding.Core.ComponentModel; +using Microsoft.DotNet.Scaffolding.Internal.CliHelpers; +using Microsoft.DotNet.Scaffolding.Internal.Services; +using Microsoft.DotNet.Scaffolding.Internal.Telemetry; +using Microsoft.DotNet.Tools.Scaffold.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.DotNet.Tools.Scaffold.Tests.Services; + +public class DotNetToolServiceTests +{ + private readonly DotNetToolService _service = new( + NullLogger.Instance, + Mock.Of(), + Mock.Of()); + + [Theory] + [InlineData("Microsoft.dotnet-scaffold", false)] + [InlineData("microsoft.dotnet-scaffold", true)] + public void GetCommands_BuiltInTool_UsesRunningAssembly(string packageName, bool isGlobalTool) + { + var tool = CreateUnavailableTool(packageName, isGlobalTool); + Dictionary? envVars = Environment.GetEnvironmentVariable(TelemetryConstants.DOTNET_SCAFFOLD_TELEMETRY_STATE) is null + ? new() { [TelemetryConstants.DOTNET_SCAFFOLD_TELEMETRY_STATE] = TelemetryConstants.TELEMETRY_STATE_DISABLED } + : null; + + var commands = _service.GetCommands(tool, envVars); + + Assert.Contains(commands, command => command.Name == "blazor-empty"); + Assert.Contains(commands, command => command.Name == "caching"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetCommands_UnavailableThirdPartyTool_DoesNotReturnBuiltInCommands(bool isGlobalTool) + { + var tool = CreateUnavailableTool("Microsoft.dotnet-scaffold-custom", isGlobalTool); + + var commands = _service.GetCommands(tool); + + Assert.Empty(commands); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void GetAllCommandsParallel_DefaultComponents_QueriesOnlyBuiltInToolWithoutRestoring(bool emptyComponents, bool isGlobalTool) + { + var service = new RecordingDotNetToolService( + localToolList: string.Join(Environment.NewLine, + "Package Id Version Commands Manifest", + "------------------------------------", + "contoso.local 1.0.0 contoso-local manifest.json", + isGlobalTool ? string.Empty : "MICROSOFT.DOTNET-SCAFFOLD 1.0.0 dotnet-scaffold manifest.json"), + globalToolList: string.Join(Environment.NewLine, + "Package Id Version Commands", + "---------------------------", + "microsoft.dotnet-scaffold 1.0.0 dotnet-scaffold", + "contoso.global 1.0.0 contoso-global")); + + var commands = service.GetAllCommandsParallel(emptyComponents ? [] : null); + + var command = Assert.Single(commands); + Assert.Equal("dotnet-scaffold", command.Key); + Assert.Equal("test-command", command.Value.Name); + + var invocations = service.Invocations.ToArray(); + Assert.Equal(3, invocations.Length); + Assert.Contains(invocations, invocation => invocation.Arguments == "tool list"); + Assert.Contains(invocations, invocation => invocation.Arguments == "tool list -g"); + Assert.DoesNotContain(invocations, invocation => invocation.Arguments == "tool restore"); + var metadataInvocation = Assert.Single(invocations, invocation => invocation.Arguments.EndsWith("get-commands", StringComparison.Ordinal)); + Assert.Contains(typeof(DotNetToolService).Assembly.Location, metadataInvocation.Arguments); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetAllCommandsParallel_ExplicitComponent_PreservesThirdPartyInvocationAndRestoreBehavior(bool isGlobalTool) + { + var service = new RecordingDotNetToolService(); + var tool = new DotNetToolInfo + { + PackageName = "Contoso.Scaffolder", + Version = "1.0.0", + Command = "contoso-scaffolder", + IsGlobalTool = isGlobalTool + }; + + var commands = service.GetAllCommandsParallel([tool]); + + var command = Assert.Single(commands); + Assert.Equal(tool.Command, command.Key); + Assert.Equal("test-command", command.Value.Name); + + var invocations = service.Invocations.ToArray(); + Assert.Equal(isGlobalTool ? 1 : 2, invocations.Length); + if (!isGlobalTool) + { + Assert.Equal("tool restore", invocations[0].Arguments); + } + + var metadataInvocation = invocations[^1]; + Assert.Equal(isGlobalTool ? tool.Command : "dotnet", Path.GetFileNameWithoutExtension(metadataInvocation.FileName)); + Assert.Equal(isGlobalTool ? "get-commands" : $"{tool.Command} get-commands", metadataInvocation.Arguments); + } + + private static DotNetToolInfo CreateUnavailableTool(string packageName, bool isGlobalTool) => new() + { + PackageName = packageName, + Version = "0.0.0", + Command = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "dotnet-scaffold.dll"), + IsGlobalTool = isGlobalTool + }; + + private sealed class RecordingDotNetToolService(string localToolList = "", string globalToolList = "") : DotNetToolService( + NullLogger.Instance, + Mock.Of(), + Mock.Of()) + { + public ConcurrentQueue<(string FileName, string Arguments)> Invocations { get; } = new(); + + protected override int ExecuteAndCaptureOutput(DotnetCliRunner runner, out string? stdOut, out string? stdErr) + { + var arguments = runner._psi.Arguments; + Invocations.Enqueue((runner._psi.FileName, arguments)); + stdErr = string.Empty; + stdOut = arguments switch + { + "tool list" => localToolList, + "tool list -g" => globalToolList, + "tool restore" => string.Empty, + _ when arguments.EndsWith("get-commands", StringComparison.Ordinal) => """ + [{"Name":"test-command","DisplayName":"Test command","DisplayCategories":["All"],"Parameters":[]}] + """, + _ => throw new InvalidOperationException($"Unexpected tool invocation: {runner._psi.FileName} {arguments}") + }; + return 0; + } + } +}