diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index e99b01a6991..d267234b9a4 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -248,9 +248,11 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] cliArgs = isSingleFile switch { false => [watchOrRunCommand, nonInteractiveSwitch, verboseSwitch, noBuildSwitch, noProfileSwitch, "--project", projectFile.FullName, "--", .. args], - true => ["run", projectFile.FullName, "--", ..args] + true => ["run", noProfileSwitch, "--file", projectFile.FullName, "--", .. args] }; + cliArgs = [.. cliArgs.Where(arg => !string.IsNullOrWhiteSpace(arg))]; + // Inject DOTNET_CLI_USE_MSBUILD_SERVER when noBuild == false - we copy the // dictionary here because we don't want to mutate the input. IDictionary? finalEnv = env; diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index 2d752faed93..d96879bb302 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -24,7 +24,7 @@ private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryIn var settingsDirectory = workingDirectory.CreateSubdirectory(".aspire"); var hivesDirectory = settingsDirectory.CreateSubdirectory("hives"); var cacheDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "cache")); - return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory); } [Fact] @@ -380,7 +380,7 @@ public async Task RunAsyncSetsVersionCheckDisabledWhenUpdateNotificationsFeature provider.GetRequiredService(), provider.GetRequiredService(), new NullDiskCache(), - (args, env, _, _, _, _) => + (args, env, _, _, _, _) => { Assert.NotNull(env); Assert.True(env.ContainsKey("ASPIRE_VERSION_CHECK_DISABLED")); @@ -428,7 +428,7 @@ public async Task RunAsyncDoesNotSetVersionCheckDisabledWhenUpdateNotificationsF provider.GetRequiredService(), provider.GetRequiredService(), new NullDiskCache(), - (args, env, _, _, _, _) => + (args, env, _, _, _, _) => { // When the feature is enabled (default), the version check env var should NOT be set if (env != null) @@ -478,7 +478,7 @@ public async Task RunAsyncDoesNotOverrideUserProvidedVersionCheckDisabledValue() provider.GetRequiredService(), provider.GetRequiredService(), new NullDiskCache(), - (args, env, _, _, _, _) => + (args, env, _, _, _, _) => { Assert.NotNull(env); Assert.True(env.ContainsKey("ASPIRE_VERSION_CHECK_DISABLED")); @@ -570,7 +570,10 @@ public async Task AddPackageAsyncUseFilesSwitchForSingleFileAppHost() var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")); await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost"); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.SingleFileAppHostEnabled]; + }); var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); var interactionService = provider.GetRequiredService(); @@ -596,14 +599,14 @@ public async Task AddPackageAsyncUseFilesSwitchForSingleFileAppHost() Assert.Contains(appHostFile.FullName, args); Assert.Contains("Aspire.Hosting.Redis@9.2.0", args); Assert.Contains("--no-restore", args); - + // Verify the order: add package PackageName --file FilePath --version Version --no-restore var addIndex = Array.IndexOf(args, "add"); var packageIndex = Array.IndexOf(args, "package"); var fileIndex = Array.IndexOf(args, "--file"); var filePathIndex = Array.IndexOf(args, appHostFile.FullName); var packageNameIndex = Array.IndexOf(args, "Aspire.Hosting.Redis@9.2.0"); - + Assert.True(addIndex < packageIndex); Assert.True(packageIndex < fileIndex); Assert.True(fileIndex < filePathIndex); @@ -659,7 +662,7 @@ public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFile() Assert.Contains("9.2.0", args); Assert.Contains("--source", args); Assert.Contains("https://api.nuget.org/v3/index.json", args); - + // Verify the order: add ProjectFile package PackageName --version Version --source Source var addIndex = Array.IndexOf(args, "add"); var projectIndex = Array.IndexOf(args, projectFile.FullName); @@ -667,13 +670,13 @@ public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFile() var packageNameIndex = Array.IndexOf(args, "Aspire.Hosting.Redis"); var versionFlagIndex = Array.IndexOf(args, "--version"); var versionValueIndex = Array.IndexOf(args, "9.2.0"); - + Assert.True(addIndex < projectIndex); Assert.True(projectIndex < packageIndex); Assert.True(packageIndex < packageNameIndex); Assert.True(packageNameIndex < versionFlagIndex); Assert.True(versionFlagIndex < versionValueIndex); - + // Should NOT contain --file or the @version format Assert.DoesNotContain("--file", args); Assert.DoesNotContain("Aspire.Hosting.Redis@9.2.0", args); @@ -727,7 +730,7 @@ public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFileWithNoRestor Assert.Contains("--version", args); Assert.Contains("9.2.0", args); Assert.Contains("--no-restore", args); - + // Verify the order: add ProjectFile package PackageName --version Version --no-restore var addIndex = Array.IndexOf(args, "add"); var projectIndex = Array.IndexOf(args, projectFile.FullName); @@ -736,14 +739,14 @@ public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFileWithNoRestor var versionFlagIndex = Array.IndexOf(args, "--version"); var versionValueIndex = Array.IndexOf(args, "9.2.0"); var noRestoreIndex = Array.IndexOf(args, "--no-restore"); - + Assert.True(addIndex < projectIndex); Assert.True(projectIndex < packageIndex); Assert.True(packageIndex < packageNameIndex); Assert.True(packageNameIndex < versionFlagIndex); Assert.True(versionFlagIndex < versionValueIndex); Assert.True(versionValueIndex < noRestoreIndex); - + // Should NOT contain --file, --source, or the @version format Assert.DoesNotContain("--file", args); Assert.DoesNotContain("--source", args); @@ -768,7 +771,7 @@ public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFileWithNoRestor public async Task GetSolutionProjectsAsync_ParsesOutputCorrectly() { using var workspace = TemporaryWorkspace.Create(outputHelper); - + // Create a fake solution file var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); await File.WriteAllTextAsync(solutionFile.FullName, "Not a real solution file."); @@ -825,7 +828,7 @@ public async Task GetSolutionProjectsAsync_ParsesOutputCorrectly() public async Task AddProjectReferenceAsync_ExecutesCorrectCommand() { using var workspace = TemporaryWorkspace.Create(outputHelper); - + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); @@ -863,6 +866,353 @@ public async Task AddProjectReferenceAsync_ExecutesCorrectCommand() Assert.Equal(0, exitCode); } + + [Fact] + public async Task RunAsyncAppliesNoLaunchProfileForSingleFileAppHost() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")); + await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost"); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.SingleFileAppHostEnabled]; + }); + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + var interactionService = provider.GetRequiredService(); + + var options = new DotNetCliRunnerInvocationOptions() + { + NoLaunchProfile = true + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = new AssertingDotNetCliRunner( + logger, + provider, + new AspireCliTelemetry(), + provider.GetRequiredService(), + provider.GetRequiredService(), + interactionService, + executionContext, + new NullDiskCache(), + (args, _, _, _, _, _) => + { + // For single-file .cs files, should include --no-launch-profile + Assert.Collection(args, + arg => Assert.Equal("run", arg), + arg => Assert.Equal("--no-launch-profile", arg), + arg => Assert.Equal("--file", arg), + arg => Assert.Equal(appHostFile.FullName, arg), + arg => Assert.Equal("--", arg) + ); + }, + 0 + ); + + var exitCode = await runner.RunAsync( + projectFile: appHostFile, + watch: false, + noBuild: false, + args: [], + env: new Dictionary(), + null, + options, + CancellationToken.None + ); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsyncDoesNotIncludeNoLaunchProfileForSingleFileAppHostWhenNotSpecified() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")); + await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost"); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.SingleFileAppHostEnabled]; + }); + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + var interactionService = provider.GetRequiredService(); + + var options = new DotNetCliRunnerInvocationOptions() + { + NoLaunchProfile = false + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = new AssertingDotNetCliRunner( + logger, + provider, + new AspireCliTelemetry(), + provider.GetRequiredService(), + provider.GetRequiredService(), + interactionService, + executionContext, + new NullDiskCache(), + (args, _, _, _, _, _) => + { + // For single-file .cs files, should NOT include --no-launch-profile when false + Assert.Collection(args, + arg => Assert.Equal("run", arg), + arg => Assert.Equal("--file", arg), + arg => Assert.Equal(appHostFile.FullName, arg), + arg => Assert.Equal("--", arg) + ); + }, + 0 + ); + + var exitCode = await runner.RunAsync( + projectFile: appHostFile, + watch: false, + noBuild: false, + args: [], + env: new Dictionary(), + null, + options, + CancellationToken.None + ); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsyncFiltersOutEmptyAndWhitespaceArguments() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + var interactionService = provider.GetRequiredService(); + + // Use watch=true and NoLaunchProfile=false to ensure some empty strings are generated + var options = new DotNetCliRunnerInvocationOptions() + { + NoLaunchProfile = false, + Debug = false + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = new AssertingDotNetCliRunner( + logger, + provider, + new AspireCliTelemetry(), + provider.GetRequiredService(), + provider.GetRequiredService(), + interactionService, + executionContext, + new NullDiskCache(), + (args, _, _, _, _, _) => + { + // Verify no empty or whitespace-only arguments exist + foreach (var arg in args) + { + Assert.False(string.IsNullOrWhiteSpace(arg), $"Found empty or whitespace argument in args: [{string.Join(", ", args)}]"); + } + }, + 0 + ); + + var exitCode = await runner.RunAsync( + projectFile: projectFile, + watch: true, // This will generate empty strings for verboseSwitch when Debug=false + noBuild: false, + args: [], + env: new Dictionary(), + null, + options, + CancellationToken.None + ); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsyncFiltersOutEmptyArgumentsForSingleFileAppHost() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")); + await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost"); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.SingleFileAppHostEnabled]; + }); + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + var interactionService = provider.GetRequiredService(); + + var options = new DotNetCliRunnerInvocationOptions() + { + NoLaunchProfile = false // This will generate an empty string for noProfileSwitch + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = new AssertingDotNetCliRunner( + logger, + provider, + new AspireCliTelemetry(), + provider.GetRequiredService(), + provider.GetRequiredService(), + interactionService, + executionContext, + new NullDiskCache(), + (args, _, _, _, _, _) => + { + // Verify no empty or whitespace-only arguments exist in single-file AppHost scenario + foreach (var arg in args) + { + Assert.False(string.IsNullOrWhiteSpace(arg), $"Found empty or whitespace argument in args: [{string.Join(", ", args)}]"); + } + + // Ensure the correct arguments are present + Assert.Collection(args, + arg => Assert.Equal("run", arg), + arg => Assert.Equal("--file", arg), + arg => Assert.Equal(appHostFile.FullName, arg), + arg => Assert.Equal("--", arg) + ); + }, + 0 + ); + + var exitCode = await runner.RunAsync( + projectFile: appHostFile, + watch: false, + noBuild: false, + args: [], + env: new Dictionary(), + null, + options, + CancellationToken.None + ); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsyncIncludesAllNonEmptyFlagsWhenEnabled() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + var interactionService = provider.GetRequiredService(); + + var options = new DotNetCliRunnerInvocationOptions() + { + NoLaunchProfile = true, + Debug = true + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = new AssertingDotNetCliRunner( + logger, + provider, + new AspireCliTelemetry(), + provider.GetRequiredService(), + provider.GetRequiredService(), + interactionService, + executionContext, + new NullDiskCache(), + (args, _, _, _, _, _) => + { + // With watch=true and Debug=true, should include --verbose + Assert.Collection(args, + arg => Assert.Equal("watch", arg), + arg => Assert.Equal("--non-interactive", arg), + arg => Assert.Equal("--verbose", arg), + arg => Assert.Equal("--no-launch-profile", arg), + arg => Assert.Equal("--project", arg), + arg => Assert.Equal(projectFile.FullName, arg), + arg => Assert.Equal("--", arg) + ); + }, + 0 + ); + + var exitCode = await runner.RunAsync( + projectFile: projectFile, + watch: true, + noBuild: false, + args: [], + env: new Dictionary(), + null, + options, + CancellationToken.None + ); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsyncCorrectlyHandlesWatchWithoutDebug() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + var interactionService = provider.GetRequiredService(); + + var options = new DotNetCliRunnerInvocationOptions() + { + NoLaunchProfile = true, + Debug = false // No debug, so no --verbose + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = new AssertingDotNetCliRunner( + logger, + provider, + new AspireCliTelemetry(), + provider.GetRequiredService(), + provider.GetRequiredService(), + interactionService, + executionContext, + new NullDiskCache(), + (args, _, _, _, _, _) => + { + // With watch=true but Debug=false, should NOT include --verbose + Assert.Collection(args, + arg => Assert.Equal("watch", arg), + arg => Assert.Equal("--non-interactive", arg), + arg => Assert.Equal("--no-launch-profile", arg), + arg => Assert.Equal("--project", arg), + arg => Assert.Equal(projectFile.FullName, arg), + arg => Assert.Equal("--", arg) + ); + }, + 0 + ); + + var exitCode = await runner.RunAsync( + projectFile: projectFile, + watch: true, + noBuild: false, + args: [], + env: new Dictionary(), + null, + options, + CancellationToken.None + ); + + Assert.Equal(0, exitCode); + } } internal sealed class AssertingDotNetCliRunner(