diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 1539c484a6f..29040ec621a 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -240,9 +240,11 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] cliArgs = isSingleFile switch { false => [watchOrRunCommand, 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 41482a8e3b3..64ae8b67d7c 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); @@ -763,6 +766,184 @@ public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFileWithNoRestor 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 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); + } } internal sealed class AssertingDotNetCliRunner(