diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 171e6f44420..ab709e8d7e2 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -263,7 +263,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken try { - if (!watch && !context.NoBuild) + if (!context.NoBuild) { // Build in CLI if either not running under extension host, or the extension reports 'build-dotnet-using-cli' capability. var extensionHasBuildCapability = extensionBackchannel is not null && await extensionBackchannel.HasCapabilityAsync(KnownCapabilities.BuildDotnetUsingCli, cancellationToken); @@ -338,7 +338,11 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Start the apphost - the runner will signal the backchannel when ready try { - // noBuild: true if either watch mode is off (we already built above) or --no-build was passed + // The app host may already have been built above (in the CLI, or by the extension). + // For non-watch runs we pass noBuild=true so dotnet run doesn't rebuild. For watch runs + // we leave builds enabled unless the user explicitly requested --no-build, so dotnet watch + // can perform its own build/incremental build and hot reload even if an up-front build + // already happened earlier in this method. // noRestore: only relevant when noBuild is false (since --no-build implies --no-restore) var noBuild = !watch || context.NoBuild; return await _runner.RunAsync( diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index d2fd7edcb39..5f5e7409ce0 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -846,6 +846,59 @@ public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsTrue_UsesWatchM Assert.True(watchModeUsed, "Expected watch mode to be enabled when defaultWatchEnabled feature flag is true"); } + [Fact] + public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsTrueAndBuildFails_ReturnsBuildFailure() + { + var runCalled = false; + + var runnerFactory = (IServiceProvider sp) => + { + var runner = new TestDotNetCliRunner(); + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => + { + options.StandardErrorCallback?.Invoke("error CS0103: The name 'MissingSymbol' does not exist in the current context"); + return 1; + }; + + runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); + + runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => + { + runCalled = true; + return Task.FromResult(0); + }; + + return runner; + }; + + var backchannelFactory = (IServiceProvider sp) => + { + var backchannel = new TestAppHostBackchannel(); + backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync; + return backchannel; + }; + + var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator(); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = projectLocatorFactory; + options.AppHostBackchannelFactory = backchannelFactory; + options.DotNetCliRunnerFactory = runnerFactory; + options.EnabledFeatures = [KnownFeatures.DefaultWatchEnabled]; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("run"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.FailedToBuildArtifacts, exitCode); + Assert.False(runCalled, "The AppHost should not be started when the initial build fails in watch mode."); + } + [Fact] public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsFalse_DoesNotUseWatchMode() {