From 5113adc471790b351c982447a3027d8d4c14a3f6 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 6 Aug 2026 14:33:26 -0700 Subject: [PATCH 1/5] Fix AppHost build failure diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f00829a-32fc-44f6-bfb8-4310c86a460c --- src/Aspire.Cli/Commands/RunCommand.cs | 4 ++- src/Aspire.Cli/Projects/ProjectLocator.cs | 9 ++++++ .../Projects/ProjectLocatorTests.cs | 28 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 49dd42a995c..5a4ae696bc2 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -584,7 +584,9 @@ await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTar } catch (ProjectLocatorException ex) { - runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "project_not_found"); + runActivity?.SetTag( + TelemetryConstants.Tags.ErrorType, + ex.FailureReason is ProjectLocatorFailureReason.ProjectFileCouldNotBeBuilt ? "build_failed" : "project_not_found"); return HandleProjectLocatorException(ex, InteractionService, Telemetry); } catch (AppHostIncompatibleException ex) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 8f1ced47a4a..61cc4feb327 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -930,6 +930,12 @@ public async Task UseOrFindAppHostProjectFileAsync(F return new AppHostProjectSearchResult(projectFile, [projectFile]); } + + if (validationResult.IsPossiblyUnbuildable) + { + logger.LogError("Project file {ProjectFile} could not be analyzed because it failed to build.", projectFile.FullName); + throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.ProjectFileCouldNotBeBuilt); + } } // If no handler matched, for .cs files check if we should search the parent directory @@ -1230,6 +1236,8 @@ ProjectLocatorFailureReason.ProjectFileDoesntExist or ProjectLocatorFailureReaso => (CliExitCodes.SdkNotInstalled, InteractionServiceStrings.NoSupportedAppHostsFound), ProjectLocatorFailureReason.ProjectFileNotAppHostProject => (CliExitCodes.FailedToFindProject, InteractionServiceStrings.SpecifiedProjectFileNotAppHostProject), + ProjectLocatorFailureReason.ProjectFileCouldNotBeBuilt + => (CliExitCodes.FailedToBuildArtifacts, InteractionServiceStrings.ProjectCouldNotBeBuilt), ProjectLocatorFailureReason.ProjectFileDoesntExist => (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionDoesntExist), ProjectLocatorFailureReason.MultipleProjectFilesFound @@ -1247,6 +1255,7 @@ internal enum ProjectLocatorFailureReason { ProjectFileDoesntExist, ProjectFileNotAppHostProject, + ProjectFileCouldNotBeBuilt, MultipleProjectFilesFound, NoProjectFileFound, AppHostsMayNotBeBuildable, diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 4d64268956d..0a69eb3fe70 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -46,6 +46,34 @@ public async Task UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotE Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); } + [Fact] + public async Task UseOrFindAppHostProjectFileReportsBuildFailureIfExplicitProjectCannotBeAnalyzed() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, ""); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile, createSettingsFile: true).DefaultTimeout(); + }); + + Assert.Equal(ErrorStrings.AppHostsMayNotBeBuildable, exception.Message); + Assert.Equal(ProjectLocatorFailureReason.ProjectFileCouldNotBeBuilt, exception.FailureReason); + + var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(exception); + Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); + Assert.Equal(InteractionServiceStrings.ProjectCouldNotBeBuilt, errorMessage); + } + [Fact] public async Task UseOrFindAppHostProjectFileUsesCachedSettingsWhenStillValidAmongMultipleAppHosts() { From 3daf0ad930ad1036d3c235e0dd07adf4efe67ac8 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 6 Aug 2026 14:43:47 -0700 Subject: [PATCH 2/5] Strengthen AppHost diagnostic regression test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f00829a-32fc-44f6-bfb8-4310c86a460c --- tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 0a69eb3fe70..c765664e9b0 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -67,7 +67,7 @@ public async Task UseOrFindAppHostProjectFileReportsBuildFailureIfExplicitProjec }); Assert.Equal(ErrorStrings.AppHostsMayNotBeBuildable, exception.Message); - Assert.Equal(ProjectLocatorFailureReason.ProjectFileCouldNotBeBuilt, exception.FailureReason); + Assert.NotEqual(ProjectLocatorFailureReason.ProjectFileDoesntExist, exception.FailureReason); var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(exception); Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); From 1328b623873d799e510e8179f3c6f742986fcb9b Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 6 Aug 2026 14:52:44 -0700 Subject: [PATCH 3/5] Handle configured AppHost build failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f00829a-32fc-44f6-bfb8-4310c86a460c --- src/Aspire.Cli/Commands/RunCommand.cs | 2 +- src/Aspire.Cli/Projects/ProjectLocator.cs | 7 +--- .../Projects/ProjectLocatorTests.cs | 38 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 5a4ae696bc2..a7fdb69b6b7 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -586,7 +586,7 @@ await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTar { runActivity?.SetTag( TelemetryConstants.Tags.ErrorType, - ex.FailureReason is ProjectLocatorFailureReason.ProjectFileCouldNotBeBuilt ? "build_failed" : "project_not_found"); + ex.FailureReason is ProjectLocatorFailureReason.AppHostsMayNotBeBuildable ? "build_failed" : "project_not_found"); return HandleProjectLocatorException(ex, InteractionService, Telemetry); } catch (AppHostIncompatibleException ex) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 61cc4feb327..661b05af955 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -934,7 +934,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F if (validationResult.IsPossiblyUnbuildable) { logger.LogError("Project file {ProjectFile} could not be analyzed because it failed to build.", projectFile.FullName); - throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.ProjectFileCouldNotBeBuilt); + throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.AppHostsMayNotBeBuildable); } } @@ -1236,8 +1236,6 @@ ProjectLocatorFailureReason.ProjectFileDoesntExist or ProjectLocatorFailureReaso => (CliExitCodes.SdkNotInstalled, InteractionServiceStrings.NoSupportedAppHostsFound), ProjectLocatorFailureReason.ProjectFileNotAppHostProject => (CliExitCodes.FailedToFindProject, InteractionServiceStrings.SpecifiedProjectFileNotAppHostProject), - ProjectLocatorFailureReason.ProjectFileCouldNotBeBuilt - => (CliExitCodes.FailedToBuildArtifacts, InteractionServiceStrings.ProjectCouldNotBeBuilt), ProjectLocatorFailureReason.ProjectFileDoesntExist => (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionDoesntExist), ProjectLocatorFailureReason.MultipleProjectFilesFound @@ -1245,7 +1243,7 @@ ProjectLocatorFailureReason.ProjectFileDoesntExist or ProjectLocatorFailureReaso ProjectLocatorFailureReason.NoProjectFileFound => (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionNotSpecifiedNoCsprojFound), ProjectLocatorFailureReason.AppHostsMayNotBeBuildable - => (CliExitCodes.FailedToFindProject, InteractionServiceStrings.UnbuildableAppHostsDetected), + => (CliExitCodes.FailedToBuildArtifacts, InteractionServiceStrings.ProjectCouldNotBeBuilt), _ => (CliExitCodes.FailedToFindProject, string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message)) }; } @@ -1255,7 +1253,6 @@ internal enum ProjectLocatorFailureReason { ProjectFileDoesntExist, ProjectFileNotAppHostProject, - ProjectFileCouldNotBeBuilt, MultipleProjectFilesFound, NoProjectFileFound, AppHostsMayNotBeBuildable, diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index c765664e9b0..06fbd3a6a89 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -74,6 +74,44 @@ public async Task UseOrFindAppHostProjectFileReportsBuildFailureIfExplicitProjec Assert.Equal(InteractionServiceStrings.ProjectCouldNotBeBuilt, errorMessage); } + [Fact] + public async Task UseOrFindAppHostProjectFileReportsBuildFailureIfConfiguredProjectCannotBeAnalyzed() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var projectDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, ""); + + var configFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName)); + await File.WriteAllTextAsync(configFile.FullName, JsonSerializer.Serialize(new + { + appHost = new + { + path = Path.GetRelativePath(workspace.WorkspaceRoot.FullName, projectFile.FullName) + } + })); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile: null, createSettingsFile: false).DefaultTimeout(); + }); + + Assert.Equal(ErrorStrings.AppHostsMayNotBeBuildable, exception.Message); + Assert.Equal(ProjectLocatorFailureReason.AppHostsMayNotBeBuildable, exception.FailureReason); + + var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(exception); + Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); + Assert.Equal(InteractionServiceStrings.ProjectCouldNotBeBuilt, errorMessage); + } + [Fact] public async Task UseOrFindAppHostProjectFileUsesCachedSettingsWhenStillValidAmongMultipleAppHosts() { From 59acaf6ec3ad90f103cff02f236dbc2405d231c0 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 6 Aug 2026 16:01:14 -0700 Subject: [PATCH 4/5] Cover explicit AppHost build failures Classify explicit AppHost directories containing only unbuildable candidates as build failures, and add unit and CLI regression coverage for the missing-SDK case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f00829a-32fc-44f6-bfb8-4310c86a460c --- src/Aspire.Cli/Projects/ProjectLocator.cs | 5 +++ .../AppHostSyntaxErrorOutputTests.cs | 38 ++++++++++++++++++- .../Projects/ProjectLocatorTests.cs | 30 +++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 661b05af955..5a974ac7f8e 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -849,6 +849,11 @@ public async Task UseOrFindAppHostProjectFileAsync(F if (appHostProjects.Count == 0) { + if (searchResults.UnbuildableSuspectedAppHostProjects.Count > 0) + { + throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.AppHostsMayNotBeBuildable); + } + if (searchResults.HasUnsupportedProjects) { throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.UnsupportedProjects); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs index 6f8db9ebfab..99ed4255a8a 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs @@ -11,7 +11,7 @@ namespace Aspire.Cli.EndToEnd.Tests; /// -/// End-to-end tests for AppHost syntax-error output. +/// End-to-end tests for AppHost failures reported before startup. /// public sealed class AppHostSyntaxErrorOutputTests(ITestOutputHelper output) { @@ -29,6 +29,20 @@ public Task RunReportsSyntaxErrorsForDotNetAppHost() timeout: TimeSpan.FromMinutes(2)); } + [Fact] + [CaptureWorkspaceOnFailure] + public Task RunReportsMissingSdkAsBuildFailureForDotNetAppHost() + { + return RunSyntaxErrorScenarioAsync( + projectName: "MissingSdkAppHost", + template: AspireTemplate.EmptyAppHost, + configureProject: WriteDotNetAppHostWithMissingSdk, + command: "aspire run --apphost MissingSdkAppHost.csproj", + expectedExitCode: 6, + outputExpectation: s_dotNetMissingSdkRunOutputExpectation, + timeout: TimeSpan.FromMinutes(2)); + } + [Fact] [CaptureWorkspaceOnFailure] public Task StartReportsSyntaxErrorsForDotNetAppHost() @@ -184,6 +198,16 @@ private static void AssertTerminalRecording(string terminalRecording, CommandOut RunCommandStrings.RecentAppHostStartupOutput ]); + private static readonly CommandOutputExpectation s_dotNetMissingSdkRunOutputExpectation = new( + RequiredText: + [ + "The project could not be built." + ], + ForbiddenText: + [ + "The --apphost option specified a project that does not exist." + ]); + private static readonly CommandOutputExpectation s_dotNetStartOutputExpectation = new( RequiredText: [ @@ -247,6 +271,18 @@ private static void WriteBrokenDotNetAppHost(string projectDirectory) """); } + private static void WriteDotNetAppHostWithMissingSdk(string projectDirectory) + { + File.WriteAllText(Path.Combine(projectDirectory, "MissingSdkAppHost.csproj"), """ + + + Exe + net10.0 + + + """); + } + private static string GetAspireSdkVersion(string appHostPath) { var firstLine = File.ReadLines(appHostPath).First(); diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 06fbd3a6a89..3b86f492946 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -1578,6 +1578,36 @@ public async Task UseOrFindAppHostProjectFileThrowsWhenDirectoryHasNoProjects() Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); } + [Fact] + public async Task UseOrFindAppHostProjectFileThrowsBuildFailureWhenDirectoryOnlyContainsPossiblyUnbuildableProject() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var projectDirectory = workspace.WorkspaceRoot.CreateSubdirectory("UnbuildableAppHost"); + var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "UnbuildableAppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + var directoryAsFileInfo = new FileInfo(projectDirectory.FullName); + + var ex = await Assert.ThrowsAsync(async () => + { + await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true).DefaultTimeout(); + }); + + Assert.Equal(ErrorStrings.AppHostsMayNotBeBuildable, ex.Message); + Assert.Equal(ProjectLocatorFailureReason.AppHostsMayNotBeBuildable, ex.FailureReason); + + var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(ex, projectOptionSpecifiedAsDirectory: true); + Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); + Assert.Equal(InteractionServiceStrings.ProjectCouldNotBeBuilt, errorMessage); + } + [Fact] public async Task UseOrFindAppHostProjectFilePromptsWhenDirectoryHasMultipleProjects() { From 8be04da51fab7abfd623cfbe4d45c0f2162a3c56 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 11:34:12 -0400 Subject: [PATCH 5/5] Surface real MSBuild errors instead of hiding them behind a resolution failure An AppHost can exist and still fail MSBuild evaluation, most commonly when Aspire.AppHost.Sdk cannot be resolved. ProjectLocator treated that signal (IsPossiblyUnbuildable) as "no such project", so the user was told the file did not exist, or that no AppHosts were found, and never saw the MSB4236 that explained the real problem. Keep the selection instead. An explicitly named file, an explicitly named directory holding one candidate, and a configured aspire.config.json path are all deliberate user choices, so honor them and let the command's existing build path print the diagnostics it already collects. That path needs no changes: it already stashes build output, reports FailedToBuildArtifacts, and displays the collected lines. Ambient discovery is deliberately unchanged. Finding only unbuildable candidates while scanning is still AppHostsMayNotBeBuildable with FailedToFindProject, because AppHostConnectionResolver.IsProjectResolutionError keys off that exit code to tell "AppHost not running" apart from "no project resolved". Remapping it makes stop/logs/describe/ps emit success-shaped output. An unverified selection is never written back to settings, since a candidate kept only because MSBuild failed was never confirmed to be an AppHost and would otherwise be silently reused by later ambient invocations. Fixes #19035 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c56ab082-7689-4734-9d39-dbca4d4e1470 --- src/Aspire.Cli/Commands/RunCommand.cs | 4 +- src/Aspire.Cli/Projects/ProjectLocator.cs | 136 +++++- .../AppHostSyntaxErrorOutputTests.cs | 8 +- .../Aspire.Cli.EndToEnd.Tests.csproj | 2 + .../AppHostConnectionResolverTests.cs | 37 ++ .../Commands/RunCommandTests.cs | 216 ++++++++++ .../Commands/UpdateCommandTests.cs | 52 +++ .../Projects/ProjectLocatorTests.cs | 404 ++++++++++++++++-- 8 files changed, 808 insertions(+), 51 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index a7fdb69b6b7..49dd42a995c 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -584,9 +584,7 @@ await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTar } catch (ProjectLocatorException ex) { - runActivity?.SetTag( - TelemetryConstants.Tags.ErrorType, - ex.FailureReason is ProjectLocatorFailureReason.AppHostsMayNotBeBuildable ? "build_failed" : "project_not_found"); + runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "project_not_found"); return HandleProjectLocatorException(ex, InteractionService, Telemetry); } catch (AppHostIncompatibleException ex) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 5a974ac7f8e..56b70a98832 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -578,7 +578,54 @@ bool IsDuplicate(AppHostProjectCandidate candidate) return settingsAppHost; } - private async Task GetValidatedAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken) + /// + /// The AppHost resolved from aspire.config.json (or migrated legacy settings), if any. + /// + /// The configured AppHost, or when none was usable. + /// + /// when MSBuild could not evaluate the configured AppHost, so it could not be + /// confirmed to be an AppHost. The selection is still honored, but it must never be persisted back to + /// settings and callers are expected to surface the underlying build diagnostics. + /// + private readonly record struct SettingsAppHostResult(FileInfo? AppHost, bool IsUnverified); + + /// + /// Determines whether lives beneath . + /// + /// + /// Case sensitivity matches the discovery walk's settings-candidate de-duplication: Windows and + /// default macOS APFS volumes are case-insensitive. + /// See https://github.com/microsoft/aspire/issues/17635. + /// + private bool IsUnderDirectory(FileInfo file, DirectoryInfo directory) + { + var pathComparison = environment.IsWindows() || environment.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + // Compare the raw paths first. The discovery walk can reach a candidate by descending through + // a symlinked subdirectory, and canonicalizing that path would relocate it outside the + // directory the user actually named. + if (IsUnder(file.FullName, directory.FullName)) + { + return true; + } + + // Otherwise canonicalize both sides, because the same directory can be spelled two ways: on + // macOS /tmp is a symlink to /private/tmp, so a candidate discovered as /private/tmp/x/App.csproj + // would not textually start with /tmp/x. See https://github.com/microsoft/aspire/issues/17626. + return IsUnder(PathNormalizer.ResolveSymlinks(file.FullName), PathNormalizer.ResolveSymlinks(directory.FullName)); + + bool IsUnder(string filePath, string directoryPath) + { + // The trailing separator keeps a sibling with a shared name prefix (".../Services2") + // from matching ".../Services". + var prefix = directoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + return filePath.StartsWith(prefix, pathComparison); + } + } + + private async Task GetValidatedAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken) { // This is reached from UseOrFindAppHostProjectFileAsync. When the configured // legacy settings point at a missing file we still want the warning to surface, @@ -587,20 +634,20 @@ bool IsDuplicate(AppHostProjectCandidate candidate) var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent: true, cancellationToken); if (settingsAppHost is null) { - return null; + return default; } var handler = projectFactory.TryGetProject(settingsAppHost); if (handler is null) { logger.LogWarning("Ignoring AppHost path '{AppHostPath}' from settings because no project handler can process it.", settingsAppHost.FullName); - return null; + return default; } var validationResult = await handler.ValidateAppHostAsync(settingsAppHost, cancellationToken); if (validationResult.IsValid) { - return settingsAppHost; + return new SettingsAppHostResult(settingsAppHost, IsUnverified: false); } var messageSuffix = validationResult.Message is { Length: > 0 } message ? $": {message}" : string.Empty; @@ -610,14 +657,19 @@ bool IsDuplicate(AppHostProjectCandidate candidate) } else if (validationResult.IsPossiblyUnbuildable) { - logger.LogWarning("Ignoring AppHost path '{AppHostPath}' from settings because it may not be a buildable AppHost project{MessageSuffix}.", settingsAppHost.FullName, messageSuffix); + // A configured AppHost is as deliberate a choice as --apphost, so keep it rather than + // falling back to discovery. Discarding it reported "No AppHosts were found ..." for a path + // the CLI had already resolved, and could silently run a different application that + // discovery happened to find. See https://github.com/microsoft/aspire/issues/19035. + logger.LogWarning("AppHost path '{AppHostPath}' from settings could not be evaluated by MSBuild and may not be buildable{MessageSuffix}.", settingsAppHost.FullName, messageSuffix); + return new SettingsAppHostResult(settingsAppHost, IsUnverified: true); } else { logger.LogWarning("Ignoring AppHost path '{AppHostPath}' from settings because it is no longer a valid AppHost project{MessageSuffix}.", settingsAppHost.FullName, messageSuffix); } - return null; + return default; } private async Task GetAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, bool silent, CancellationToken cancellationToken) @@ -849,8 +901,39 @@ public async Task UseOrFindAppHostProjectFileAsync(F if (appHostProjects.Count == 0) { - if (searchResults.UnbuildableSuspectedAppHostProjects.Count > 0) + // FindAppHostProjectFilesAsync also folds in the AppHost configured in + // aspire.config.json (AddSettingsAppHostCandidateAsync searches parent + // directories), so the unbuildable set can contain a project that does not live + // under the directory the user named. Auto-selecting a project that is both + // outside the requested directory and never validated would be a guess, so only + // consider candidates actually found beneath it. + var unbuildableInDirectory = searchResults.UnbuildableSuspectedAppHostProjects + .Where(c => IsUnderDirectory(c.AppHostFile, directory)) + .ToList(); + + // The user pointed at this directory, and it holds exactly one candidate that only + // failed because MSBuild could not evaluate it. Selecting it is the same intent as + // naming the file, and it lets the caller's build surface the real MSBuild + // diagnostics instead of a resolution error that hides them. See + // https://github.com/microsoft/aspire/issues/19035. + if (unbuildableInDirectory.Count == 1) + { + var unbuildableAppHost = unbuildableInDirectory[0].AppHostFile; + logger.LogDebug( + "Selecting AppHost project file {ProjectFile} in directory {Directory} even though MSBuild could not evaluate it.", + unbuildableAppHost.FullName, + directory.FullName); + + // Deliberately skip CreateSettingsFileAsync: this candidate was never confirmed to + // be an AppHost, so persisting it would make later ambient invocations silently + // reuse an unverified guess. + return new AppHostProjectSearchResult(unbuildableAppHost, [unbuildableAppHost]); + } + + if (unbuildableInDirectory.Count > 1) { + // Several broken candidates under one directory is a genuine ambiguity rather than + // a user selection, so this stays a project-resolution failure. throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.AppHostsMayNotBeBuildable); } @@ -938,8 +1021,18 @@ public async Task UseOrFindAppHostProjectFileAsync(F if (validationResult.IsPossiblyUnbuildable) { - logger.LogError("Project file {ProjectFile} could not be analyzed because it failed to build.", projectFile.FullName); - throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.AppHostsMayNotBeBuildable); + // The user named this exact file and it does exist. MSBuild simply could not + // evaluate it (unresolvable Aspire.AppHost.Sdk, malformed XML, ...), so keep it + // selected and let the caller's build print the real MSB4236/CS diagnostics. + // Reporting a resolution failure here produced the misleading "the --apphost + // option specified a project that does not exist" in + // https://github.com/microsoft/aspire/issues/19035. + logger.LogDebug( + "Selecting explicitly specified AppHost {ProjectFile} even though MSBuild could not evaluate it.", + projectFile.FullName); + + // Deliberately skip CreateSettingsFileAsync: see the explicit-directory path above. + return new AppHostProjectSearchResult(projectFile, [projectFile]); } } @@ -957,13 +1050,16 @@ public async Task UseOrFindAppHostProjectFileAsync(F } } - var settingsAppHost = await GetValidatedAppHostProjectFileFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: true, cancellationToken); + var settingsResult = await GetValidatedAppHostProjectFileFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: true, cancellationToken); + var settingsAppHost = settingsResult.AppHost; if (settingsAppHost is not null && multipleAppHostProjectsFoundBehavior is not MultipleAppHostProjectsFoundBehavior.None) { logger.LogDebug("Using AppHost path from settings without scanning: {AppHost}", settingsAppHost.FullName); - if (createSettingsFile) + // An unverified selection is never persisted: rewriting settings would turn a candidate that + // was only kept because MSBuild failed into a confirmed choice. + if (createSettingsFile && !settingsResult.IsUnverified) { await CreateSettingsFileAsync(settingsAppHost, cancellationToken); } @@ -1026,8 +1122,14 @@ public async Task UseOrFindAppHostProjectFileAsync(F : StringComparison.Ordinal; if (settingsAppHost is not null - && results.BuildableAppHost.Any(c => string.Equals(c.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))) + && (settingsResult.IsUnverified + || results.BuildableAppHost.Any(c => string.Equals(c.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)))) { + // An unverified configured AppHost can never appear in BuildableAppHost by + // construction, but it is still an explicit user choice. Honoring it here keeps this + // branch consistent with the single-candidate branch above, which already prefers the + // configured AppHost, and lets the caller's build surface the real MSBuild error + // instead of prompting for (or silently running) a different application. logger.LogDebug("Using previously-selected AppHost from settings: {AppHost}", settingsAppHost.FullName); selectedAppHost = settingsAppHost; } @@ -1044,7 +1146,13 @@ public async Task UseOrFindAppHostProjectFileAsync(F } } - if (createSettingsFile) + // A selection that came from unverified settings must not be persisted (see the early-return + // above); this path is reached when MultipleAppHostProjectsFoundBehavior.None skipped it. + var selectionIsUnverifiedSettingsAppHost = settingsResult.IsUnverified + && selectedAppHost is not null + && string.Equals(selectedAppHost.FullName, settingsAppHost?.FullName, environment.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + if (createSettingsFile && !selectionIsUnverifiedSettingsAppHost) { await CreateSettingsFileAsync(selectedAppHost!, cancellationToken); } @@ -1248,7 +1356,7 @@ ProjectLocatorFailureReason.ProjectFileDoesntExist or ProjectLocatorFailureReaso ProjectLocatorFailureReason.NoProjectFileFound => (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionNotSpecifiedNoCsprojFound), ProjectLocatorFailureReason.AppHostsMayNotBeBuildable - => (CliExitCodes.FailedToBuildArtifacts, InteractionServiceStrings.ProjectCouldNotBeBuilt), + => (CliExitCodes.FailedToFindProject, InteractionServiceStrings.UnbuildableAppHostsDetected), _ => (CliExitCodes.FailedToFindProject, string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message)) }; } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs index 99ed4255a8a..e19bf1a88d5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs @@ -201,11 +201,17 @@ private static void AssertTerminalRecording(string terminalRecording, CommandOut private static readonly CommandOutputExpectation s_dotNetMissingSdkRunOutputExpectation = new( RequiredText: [ + // https://github.com/microsoft/aspire/issues/19035: the whole point of the fix is that the + // MSBuild SDK-resolution failure reaches the user. Match on the bare error code because the + // full sentence ("The SDK 'Missing.AppHost.Sdk' specified could not be found.") is long + // enough to be wrapped across lines in the terminal recording. + "MSB4236", "The project could not be built." ], ForbiddenText: [ - "The --apphost option specified a project that does not exist." + InteractionServiceStrings.ProjectOptionDoesntExist, + InteractionServiceStrings.UnbuildableAppHostsDetected ]); private static readonly CommandOutputExpectation s_dotNetStartOutputExpectation = new( diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj index 23c09b4844c..a30ab35b37c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj +++ b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj @@ -73,6 +73,7 @@ + @@ -83,6 +84,7 @@ + diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs index 962c1082886..383a39e3af0 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs @@ -138,6 +138,43 @@ public async Task ResolveConnectionAsync_WithExplicitDirectoryAndMultipleAppHost Assert.Equal(InteractionServiceStrings.ProjectOptionSpecifiedDirectoryContainsMultipleAppHosts, result.ErrorMessage); } + [Fact] + public async Task ResolveConnectionAsync_WithExplicitDirectoryAndOnlyUnbuildableAppHosts_ReturnsProjectResolutionError() + { + // Connection commands (stop, logs, describe, ps) decide between "the AppHost is not running" and + // "we could not resolve a project" purely from the exit code. A resolution failure that is not + // classified as one produces success-shaped output for a command that resolved nothing. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("Apps"); + var interactionService = new TestInteractionService(); + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.AppHostsMayNotBeBuildable) + }; + var resolver = new AppHostConnectionResolver( + new TestAuxiliaryBackchannelMonitor(), + interactionService, + projectLocator, + executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), + NullLogger.Instance, + new ProfilingTelemetry(new ConfigurationBuilder().Build())); + + var result = await resolver.ResolveConnectionAsync( + new FileInfo(appHostDirectory.FullName), + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.True(result.IsProjectResolutionError); + Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode); + Assert.Equal(InteractionServiceStrings.UnbuildableAppHostsDetected, result.ErrorMessage); + } + [Fact] public async Task ResolveConnectionAsync_WithExplicitDirectoryAndNoAppHosts_ReturnsDirectorySpecificError() { diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index a7a2f1b25f4..4cf5b880cb7 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -9,6 +9,7 @@ using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; +using Aspire.Cli.Configuration; using Aspire.Cli.Diagnostics; using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; @@ -616,6 +617,221 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode( Assert.Equal(CliExitCodes.FailedToFindProject, exitCode); } + // https://github.com/microsoft/aspire/issues/19035. An AppHost that MSBuild cannot even evaluate + // (missing Aspire.AppHost.Sdk, malformed project XML) must reach the command's real build path so the + // original MSBuild diagnostics are printed. Replacing them with a bare "The project could not be + // built." tells the user nothing about what to fix. + private const string MissingSdkBuildError = "AppHost.csproj : error MSB4236: The SDK 'Aspire.AppHost.Sdk/0.0.0-does-not-exist' specified could not be found."; + + private static void WriteUnbuildableAppHostProject(FileInfo projectFile) + { + Directory.CreateDirectory(projectFile.DirectoryName!); + File.WriteAllText(projectFile.FullName, """ + + + + Exe + net10.0 + + + """); + } + + /// + /// Configures a runner whose MSBuild evaluation always fails (so the AppHost can only ever be + /// classified as possibly unbuildable) and whose build emits . + /// + private static TestDotNetCliRunner CreateUnevaluatableAppHostRunner() + { + var runner = new TestDotNetCliRunner(); + + // A non-zero exit with no JSON payload is what DotNetCliRunner returns when MSBuild cannot + // evaluate the project at all, which is what produces the possibly-unbuildable classification. + runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) => (1, null); + runner.BuildAsyncCallback = (_, _, buildOptions, _) => + { + buildOptions.StandardErrorCallback?.Invoke(MissingSdkBuildError); + return 1; + }; + + return runner; + } + + [Fact] + public async Task RunCommand_WhenExplicitAppHostCannotBeEvaluated_SurfacesMSBuildDiagnosticsAndBuildFailureExitCode() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj")); + WriteUnbuildableAppHostProject(appHostProjectFile); + + var interactionService = new TestInteractionService(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.DotNetCliRunnerFactory = _ => CreateUnevaluatableAppHostRunner(); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"run --apphost {appHostProjectFile.FullName}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); + Assert.Contains(interactionService.DisplayedLines, line => line.Line == MissingSdkBuildError); + Assert.Contains(InteractionServiceStrings.ProjectCouldNotBeBuilt, interactionService.DisplayedErrors); + } + + [Fact] + public async Task RunCommand_WhenConfiguredAppHostCannotBeEvaluated_SurfacesMSBuildDiagnosticsAndBuildFailureExitCode() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj")); + WriteUnbuildableAppHostProject(appHostProjectFile); + + await File.WriteAllTextAsync( + Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), + JsonSerializer.Serialize(new { appHost = new { path = "AppHost/AppHost.csproj" } })); + + var interactionService = new TestInteractionService(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.DotNetCliRunnerFactory = _ => CreateUnevaluatableAppHostRunner(); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); + Assert.Contains(interactionService.DisplayedLines, line => line.Line == MissingSdkBuildError); + Assert.Contains(InteractionServiceStrings.ProjectCouldNotBeBuilt, interactionService.DisplayedErrors); + } + + [Fact] + public async Task RunCommand_WhenAmbientDiscoveryOnlyFindsUnbuildableAppHosts_ReportsProjectResolutionFailure() + { + // Nothing was named by the user here, so this stays a project-resolution failure: the CLI never + // built anything and must keep the pre-existing message, exit code, and telemetry tag. + using var fixture = new TelemetryFixture(); + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj")); + WriteUnbuildableAppHostProject(appHostProjectFile); + + var interactionService = new TestInteractionService(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.DotNetCliRunnerFactory = _ => CreateUnevaluatableAppHostRunner(); + options.TelemetryFactory = _ => fixture.Telemetry; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.FailedToFindProject, exitCode); + Assert.Contains(InteractionServiceStrings.UnbuildableAppHostsDetected, interactionService.DisplayedErrors); + + Assert.NotNull(fixture.CapturedActivity); + var tags = fixture.CapturedActivity.TagObjects.ToDictionary(t => t.Key, t => t.Value); + Assert.Equal("project_not_found", tags[TelemetryConstants.Tags.ErrorType]); + } + + [Fact] + public async Task RunCommand_WhenExplicitAppHostCannotBeEvaluated_TagsRunActivityAsBuildFailure() + { + using var fixture = new TelemetryFixture(); + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj")); + WriteUnbuildableAppHostProject(appHostProjectFile); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DotNetCliRunnerFactory = _ => CreateUnevaluatableAppHostRunner(); + options.TelemetryFactory = _ => fixture.Telemetry; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"run --apphost {appHostProjectFile.FullName}"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.NotNull(fixture.CapturedActivity); + var tags = fixture.CapturedActivity.TagObjects.ToDictionary(t => t.Key, t => t.Value); + Assert.Equal("build_failed", tags[TelemetryConstants.Tags.ErrorType]); + } + + [Fact] + public async Task RunCommand_WhenUnverifiedAppHostBuildsButIsNotAnAppHost_FailsInsteadOfWaitingForBackchannel() + { + // A candidate kept only because MSBuild could not evaluate it has never been confirmed to be an + // AppHost. Once the build repairs evaluation and proves it is an ordinary project, the existing + // compatibility gate must reject it; launching it would hang until the startup timeout waiting + // for a backchannel that never arrives. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj")); + WriteUnbuildableAppHostProject(appHostProjectFile); + + var evaluationCount = 0; + var interactionService = new TestInteractionService(); + var backchannelWaited = false; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) => + { + // First evaluation (project resolution) fails, so the project is only ever a + // possibly-unbuildable candidate. Later evaluations succeed and prove the project is + // an ordinary library, not an AppHost. + if (Interlocked.Increment(ref evaluationCount) == 1) + { + return (1, null); + } + + return (0, JsonDocument.Parse(""" + { + "Properties": { "IsAspireHost": "false", "AspireHostingSDKVersion": null }, + "Items": {} + } + """)); + }; + runner.BuildAsyncCallback = (_, _, _, _) => 0; + runner.RunAsyncCallback = async (_, _, _, _, _, _, _, _, ct) => + { + backchannelWaited = true; + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return 0; + }; + return runner; + }; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"run --apphost {appHostProjectFile.FullName}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode); + Assert.Contains(ErrorStrings.ProjectIsNotAppHost, interactionService.DisplayedErrors); + Assert.False(backchannelWaited, "The run must fail before the AppHost process is launched."); + } + [Fact] public async Task RunCommand_WithDetachFlag_DoesNotShowUpdateNotification() { diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 71c3516a79a..cdd17acfb37 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -74,6 +74,58 @@ public async Task UpdateCommandFailsFastWhenNonInteractiveWithoutYes(string comm Assert.Equal(string.Format(System.Globalization.CultureInfo.CurrentCulture, SharedCommandStrings.NonInteractiveRequiresYesFormat, "update"), error.Message); } + [Fact] + public async Task UpdateCommand_WhenExplicitAppHostHasUnresolvableSdk_ReachesProjectUpdater() + { + // https://github.com/microsoft/aspire/issues/19035. `aspire update` is the recovery tool for a + // pinned Aspire.AppHost.Sdk that can no longer be restored, so rewriting that pin is exactly what + // the user is asking for. Failing inside project resolution makes the break unrecoverable. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostProjectFile.FullName, """ + + + + """); + + FileInfo? updatedProjectFile = null; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => new TestInteractionService(); + + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + // MSBuild cannot evaluate a project whose SDK cannot be resolved, so every property + // query fails until the pin is rewritten. + runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) => (1, null); + return runner; + }; + + options.ProjectUpdaterFactory = _ => new TestProjectUpdater() + { + UpdateProjectAsyncCallback = (context, _) => + { + updatedProjectFile = context.AppHostFile; + return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true }); + } + }; + + options.PackagingServiceFactory = _ => new TestPackagingService(); + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"update --apphost {appHostProjectFile.FullName}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal(appHostProjectFile.FullName, updatedProjectFile?.FullName); + } + [Fact] public async Task UpdateCommand_WhenProjectOptionSpecified_PassesProjectFileToProjectLocator() { diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 3b86f492946..78d8fc27955 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -47,8 +47,12 @@ public async Task UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotE } [Fact] - public async Task UseOrFindAppHostProjectFileReportsBuildFailureIfExplicitProjectCannotBeAnalyzed() + public async Task UseOrFindAppHostProjectFileKeepsExplicitAppHostThatCannotBeEvaluated() { + // https://github.com/microsoft/aspire/issues/19035. An AppHost whose MSBuild evaluation fails + // (unresolvable Aspire.AppHost.Sdk, malformed XML) is classified possibly-unbuildable. The user + // named this exact file, so it must stay selected: only the command's real build path can print + // the MSB4236/CS diagnostics that tell the user what to fix. using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); @@ -61,37 +65,150 @@ public async Task UseOrFindAppHostProjectFileReportsBuildFailureIfExplicitProjec var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); - var exception = await Assert.ThrowsAsync(async () => + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(projectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal(projectFile.FullName, Assert.Single(result.AllProjectFileCandidates).FullName); + + // Never persist a candidate that was never confirmed to be an AppHost: a later ambient + // invocation would silently reuse the unverified guess. + Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName))); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileKeepsConfiguredAppHostThatCannotBeEvaluatedAndIgnoresHealthyDecoy() + { + // A configured AppHost is as explicit as --apphost. Falling back to discovery would silently run + // a completely different application (the "decoy" below) and rewrite config to point at it. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var brokenDirectory = workspace.WorkspaceRoot.CreateSubdirectory("BrokenAppHost"); + var brokenAppHost = new FileInfo(Path.Combine(brokenDirectory.FullName, "BrokenAppHost.csproj")); + await File.WriteAllTextAsync(brokenAppHost.FullName, ""); + + var decoyDirectory = workspace.WorkspaceRoot.CreateSubdirectory("HealthyAppHost"); + var decoyAppHost = new FileInfo(Path.Combine(decoyDirectory.FullName, "HealthyAppHost.csproj")); + await File.WriteAllTextAsync(decoyAppHost.FullName, ""); + + var aspireSettingsDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire")); + aspireSettingsDir.Create(); + var aspireSettingsFile = new FileInfo(Path.Combine(aspireSettingsDir.FullName, "settings.json")); + await File.WriteAllTextAsync(aspireSettingsFile.FullName, JsonSerializer.Serialize(new { - await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile, createSettingsFile: true).DefaultTimeout(); - }); + appHostPath = Path.GetRelativePath(aspireSettingsDir.FullName, brokenAppHost.FullName).Replace(Path.DirectorySeparatorChar, '/') + })); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = file => file.FullName == brokenAppHost.FullName + ? new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + : new AppHostValidationResult(IsValid: true) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + + // The real ConfigurationService is required so that persisting a selection would actually + // migrate the legacy settings file to aspire.config.json on disk. + var globalSettingsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.global.json")); + var configurationService = new ConfigurationService(new ConfigurationBuilder().Build(), executionContext, globalSettingsFile, NullLogger.Instance); + var projectLocator = CreateProjectLocator(executionContext, configurationService: configurationService, projectFactory: projectFactory); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile: null, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); - Assert.Equal(ErrorStrings.AppHostsMayNotBeBuildable, exception.Message); - Assert.NotEqual(ProjectLocatorFailureReason.ProjectFileDoesntExist, exception.FailureReason); + Assert.Equal(brokenAppHost.FullName, result.SelectedProjectFile?.FullName); - var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(exception); - Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); - Assert.Equal(InteractionServiceStrings.ProjectCouldNotBeBuilt, errorMessage); + // Never persist a selection that was never confirmed to be an AppHost. Migrating the legacy + // settings file here would promote an unverified guess into the modern config format. + Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName))); } [Fact] - public async Task UseOrFindAppHostProjectFileReportsBuildFailureIfConfiguredProjectCannotBeAnalyzed() + public async Task UseOrFindAppHostProjectFileKeepsConfiguredAppHostOutsideAmbientDiscoveryRoot() { + // The configured path can point outside the working directory that ambient discovery scans. + // Such an AppHost is unreachable by discovery, so discarding it strands the user completely. using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var projectDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); - var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "AppHost.csproj")); - await File.WriteAllTextAsync(projectFile.FullName, ""); + var outsideDirectory = workspace.WorkspaceRoot.CreateSubdirectory("outside"); + var outsideAppHost = new FileInfo(Path.Combine(outsideDirectory.FullName, "OutsideAppHost.csproj")); + await File.WriteAllTextAsync(outsideAppHost.FullName, ""); - var configFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName)); - await File.WriteAllTextAsync(configFile.FullName, JsonSerializer.Serialize(new + var workingDirectory = workspace.WorkspaceRoot.CreateSubdirectory("work"); + var configPath = Path.Combine(workingDirectory.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new { appHost = new { - path = Path.GetRelativePath(workspace.WorkspaceRoot.FullName, projectFile.FullName) + path = Path.GetRelativePath(workingDirectory.FullName, outsideAppHost.FullName).Replace(Path.DirectorySeparatorChar, '/') } })); + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + }; + var executionContext = CreateExecutionContext(workingDirectory); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile: null, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: false, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(outsideAppHost.FullName, result.SelectedProjectFile?.FullName); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileThrowsWhenAmbientDiscoveryOnlyFindsUnbuildableAppHosts() + { + // Ambient discovery is a guess, not a user choice. Nothing was named and nothing was built, so + // the pre-existing "no buildable AppHosts" contract (message and project-resolution exit code) + // must survive the explicit-selection change. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var ex = await Assert.ThrowsAsync(async () => + { + await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile: null, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: false, + CancellationToken.None).DefaultTimeout(); + }); + + Assert.Equal(ErrorStrings.AppHostsMayNotBeBuildable, ex.Message); + Assert.Equal(ProjectLocatorFailureReason.AppHostsMayNotBeBuildable, ex.FailureReason); + + var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(ex); + Assert.Equal(CliExitCodes.FailedToFindProject, exitCode); + Assert.Equal(InteractionServiceStrings.UnbuildableAppHostsDetected, errorMessage); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileThrowsSpecificDiagnosticWhenExplicitFileIsMissing() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Missing.csproj")); + var projectFactory = new TestAppHostProjectFactory { ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) @@ -99,17 +216,49 @@ await File.WriteAllTextAsync(configFile.FullName, JsonSerializer.Serialize(new var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); - var exception = await Assert.ThrowsAsync(async () => + var ex = await Assert.ThrowsAsync(async () => { - await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile: null, createSettingsFile: false).DefaultTimeout(); + await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: false, + CancellationToken.None).DefaultTimeout(); }); - Assert.Equal(ErrorStrings.AppHostsMayNotBeBuildable, exception.Message); - Assert.Equal(ProjectLocatorFailureReason.AppHostsMayNotBeBuildable, exception.FailureReason); + Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); + Assert.Equal(ProjectLocatorFailureReason.ProjectFileDoesntExist, ex.FailureReason); + + var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(ex); + Assert.Equal(CliExitCodes.FailedToFindProject, exitCode); + Assert.Equal(InteractionServiceStrings.ProjectOptionDoesntExist, errorMessage); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileThrowsSpecificDiagnosticWhenExplicitFileIsDefinitelyNotAnAppHost() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "WebApp.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, ""); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); - var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(exception); - Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); - Assert.Equal(InteractionServiceStrings.ProjectCouldNotBeBuilt, errorMessage); + var ex = await Assert.ThrowsAsync(async () => + { + await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: false, + CancellationToken.None).DefaultTimeout(); + }); + + Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); + Assert.Equal(ProjectLocatorFailureReason.ProjectFileDoesntExist, ex.FailureReason); } [Fact] @@ -399,10 +548,8 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new Assert.Equal(guestAppHostFile.FullName, candidate.FullName); } - [Theory] - [InlineData(true, false)] - [InlineData(false, true)] - public async Task UseOrFindAppHostProjectFileFallsBackToDiscoveryWhenConfiguredAppHostIsInvalid(bool isUnsupported, bool isPossiblyUnbuildable) + [Fact] + public async Task UseOrFindAppHostProjectFileFallsBackToDiscoveryWhenConfiguredAppHostIsUnsupported() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); @@ -432,7 +579,7 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new } if (projectFile.FullName == configuredAppHostProjectFile.FullName) { - return new AppHostValidationResult(IsValid: false, IsUnsupported: isUnsupported, IsPossiblyUnbuildable: isPossiblyUnbuildable); + return new AppHostValidationResult(IsValid: false, IsUnsupported: true); } return new AppHostValidationResult(IsValid: false); } @@ -450,6 +597,62 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new Assert.Equal(realAppHostProjectFile.FullName, result.SelectedProjectFile?.FullName); } + [Fact] + public async Task UseOrFindAppHostProjectFileKeepsConfiguredAppHostThatCannotBeEvaluatedWhenListingCandidates() + { + // Candidate-listing mode (the extension's `get-apphosts`) still enumerates every discovered + // AppHost, but the configured selection must not silently move to a different application just + // because MSBuild could not evaluate the configured one. See + // https://github.com/microsoft/aspire/issues/19035. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var configuredAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("ConfiguredAppHost"); + var configuredAppHostProjectFile = new FileInfo(Path.Combine(configuredAppHostDirectory.FullName, "ConfiguredAppHost.csproj")); + await File.WriteAllTextAsync(configuredAppHostProjectFile.FullName, "Not a real apphost"); + + var realAppHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "RealAppHost.csproj")); + await File.WriteAllTextAsync(realAppHostProjectFile.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new + { + appHost = new + { + path = Path.GetRelativePath(workspace.WorkspaceRoot.FullName, configuredAppHostProjectFile.FullName).Replace(Path.DirectorySeparatorChar, '/') + } + })); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = projectFile => + { + if (projectFile.FullName == realAppHostProjectFile.FullName) + { + return new AppHostValidationResult(IsValid: true); + } + if (projectFile.FullName == configuredAppHostProjectFile.FullName) + { + return new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true); + } + return new AppHostValidationResult(IsValid: false); + } + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile: null, + multipleAppHostProjectsFoundBehavior: MultipleAppHostProjectsFoundBehavior.None, + createSettingsFile: false, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(configuredAppHostProjectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal( + [realAppHostProjectFile.FullName, configuredAppHostProjectFile.FullName], + result.AllProjectFileCandidates.Select(f => f.FullName)); + } + [Fact] public async Task UseOrFindAppHostProjectFileScansWhenCandidateListingModeHasValidSettings() { @@ -1579,13 +1782,48 @@ public async Task UseOrFindAppHostProjectFileThrowsWhenDirectoryHasNoProjects() } [Fact] - public async Task UseOrFindAppHostProjectFileThrowsBuildFailureWhenDirectoryOnlyContainsPossiblyUnbuildableProject() + public async Task UseOrFindAppHostProjectFileKeepsSingleUnbuildableAppHostInExplicitDirectory() { + // `aspire run --apphost ./MyApp` where MyApp holds exactly one AppHost is the same user intent + // as naming the file, so it must behave the same: select it and let the build report the error. using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var projectDirectory = workspace.WorkspaceRoot.CreateSubdirectory("UnbuildableAppHost"); var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "UnbuildableAppHost.csproj")); - await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + await File.WriteAllTextAsync(projectFile.FullName, ""); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + var directoryAsFileInfo = new FileInfo(projectDirectory.FullName); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + directoryAsFileInfo, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(projectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName))); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileThrowsWhenExplicitDirectoryHasMultipleUnbuildableAppHosts() + { + // Two broken candidates under one directory is a genuine ambiguity, not a user selection, so + // the project-resolution contract (message plus exit code) is preserved. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var projectDirectory = workspace.WorkspaceRoot.CreateSubdirectory("Unbuildable"); + var firstProjectDirectory = projectDirectory.CreateSubdirectory("FirstAppHost"); + var first = new FileInfo(Path.Combine(firstProjectDirectory.FullName, "FirstAppHost.csproj")); + await File.WriteAllTextAsync(first.FullName, ""); + var secondProjectDirectory = projectDirectory.CreateSubdirectory("SecondAppHost"); + var second = new FileInfo(Path.Combine(secondProjectDirectory.FullName, "SecondAppHost.csproj")); + await File.WriteAllTextAsync(second.FullName, ""); var projectFactory = new TestAppHostProjectFactory { @@ -1597,15 +1835,115 @@ public async Task UseOrFindAppHostProjectFileThrowsBuildFailureWhenDirectoryOnly var ex = await Assert.ThrowsAsync(async () => { - await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true).DefaultTimeout(); + await projectLocator.UseOrFindAppHostProjectFileAsync( + directoryAsFileInfo, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: false, + CancellationToken.None).DefaultTimeout(); }); Assert.Equal(ErrorStrings.AppHostsMayNotBeBuildable, ex.Message); Assert.Equal(ProjectLocatorFailureReason.AppHostsMayNotBeBuildable, ex.FailureReason); var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(ex, projectOptionSpecifiedAsDirectory: true); - Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); - Assert.Equal(InteractionServiceStrings.ProjectCouldNotBeBuilt, errorMessage); + Assert.Equal(CliExitCodes.FailedToFindProject, exitCode); + Assert.Equal(InteractionServiceStrings.UnbuildableAppHostsDetected, errorMessage); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileDoesNotSelectUnbuildableConfiguredAppHostOutsideExplicitDirectory() + { + // The discovery walk folds the AppHost configured in a parent directory into its results, so an + // explicitly-named directory could otherwise "find" a project that is not under it at all. + // Auto-selecting a project that the user did not point at and that was never validated would be + // a guess, so this stays the pre-existing empty-directory failure. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var brokenDirectory = workspace.WorkspaceRoot.CreateSubdirectory("BrokenAppHost"); + var brokenAppHost = new FileInfo(Path.Combine(brokenDirectory.FullName, "BrokenAppHost.csproj")); + await File.WriteAllTextAsync(brokenAppHost.FullName, ""); + + var emptyDirectory = workspace.WorkspaceRoot.CreateSubdirectory("Services"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new + { + appHost = new + { + path = Path.GetRelativePath(workspace.WorkspaceRoot.FullName, brokenAppHost.FullName).Replace(Path.DirectorySeparatorChar, '/') + } + })); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var ex = await Assert.ThrowsAsync(async () => + { + await projectLocator.UseOrFindAppHostProjectFileAsync( + new FileInfo(emptyDirectory.FullName), + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: false, + CancellationToken.None).DefaultTimeout(); + }); + + Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); + Assert.Equal(ProjectLocatorFailureReason.ProjectFileDoesntExist, ex.FailureReason); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileKeepsUnbuildableConfiguredAppHostWhenMultipleHealthyAppHostsExist() + { + // An unverified configured AppHost can never appear in the buildable candidate set, so matching + // the selection against that set alone would silently drop it and prompt (or return nothing) as + // if the user had never configured anything. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var brokenDirectory = workspace.WorkspaceRoot.CreateSubdirectory("BrokenAppHost"); + var brokenAppHost = new FileInfo(Path.Combine(brokenDirectory.FullName, "BrokenAppHost.csproj")); + await File.WriteAllTextAsync(brokenAppHost.FullName, ""); + + var firstHealthyDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstHealthyAppHost"); + var firstHealthy = new FileInfo(Path.Combine(firstHealthyDirectory.FullName, "FirstHealthyAppHost.csproj")); + await File.WriteAllTextAsync(firstHealthy.FullName, ""); + + var secondHealthyDirectory = workspace.WorkspaceRoot.CreateSubdirectory("SecondHealthyAppHost"); + var secondHealthy = new FileInfo(Path.Combine(secondHealthyDirectory.FullName, "SecondHealthyAppHost.csproj")); + await File.WriteAllTextAsync(secondHealthy.FullName, ""); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new + { + appHost = new + { + path = Path.GetRelativePath(workspace.WorkspaceRoot.FullName, brokenAppHost.FullName).Replace(Path.DirectorySeparatorChar, '/') + } + })); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = file => file.FullName == brokenAppHost.FullName + ? new AppHostValidationResult(IsValid: false, IsPossiblyUnbuildable: true) + : new AppHostValidationResult(IsValid: true) + }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + // None is the candidate-listing mode used by the VS Code extension, which reaches the scan + // rather than the settings early-return. + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile: null, + MultipleAppHostProjectsFoundBehavior.None, + createSettingsFile: false, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(brokenAppHost.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal( + new[] { firstHealthy.FullName, secondHealthy.FullName, brokenAppHost.FullName }, + result.AllProjectFileCandidates.Select(f => f.FullName).ToArray()); } [Fact]