From e79b397e3c066e04fe0cd458368c010a10493d42 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 17 Mar 2026 11:59:26 +0800 Subject: [PATCH 1/4] Revert "Show apphost stdout in non-detached runs (#15225)" This reverts commit 603263ad7c460ea8762ce76a71eda80528dedda8. --- .../Projects/DotNetAppHostProject.cs | 8 +- .../Projects/GuestAppHostProject.cs | 7 +- src/Aspire.Cli/Projects/GuestRuntime.cs | 2 +- .../Projects/ProcessGuestLauncher.cs | 21 +--- src/Aspire.Cli/Utils/OutputCollector.cs | 30 ++--- .../AppHostConsoleLogTests.cs | 115 ------------------ .../Commands/RunCommandTests.cs | 78 ------------ .../Projects/ProcessGuestLauncherTests.cs | 34 ------ .../Utils/OutputCollectorTests.cs | 18 --- tests/Shared/Hex1bAutomatorTestHelpers.cs | 31 +---- 10 files changed, 22 insertions(+), 322 deletions(-) delete mode 100644 tests/Aspire.Cli.EndToEnd.Tests/AppHostConsoleLogTests.cs delete mode 100644 tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index b500532975b..0ff8a01fcaf 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -303,13 +303,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Create collector and store in context for exception handling // This must be set BEFORE signaling build completion to avoid a race condition - var runOutputCollector = new OutputCollector( - _fileLoggerProvider, - "AppHost", - (stream, line) => _interactionService.WriteConsoleLog( - line, - type: "running", - isErrorMessage: stream == "stderr")); + var runOutputCollector = new OutputCollector(_fileLoggerProvider, "AppHost"); context.OutputCollector = runOutputCollector; // Signal that build/preparation is complete diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index caddc9b0f95..2eac5008a5f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -504,10 +504,7 @@ await GenerateCodeViaRpcAsync( } else { - launcher = _guestRuntime.CreateDefaultLauncher((stream, line) => _interactionService.WriteConsoleLog( - line, - type: "running", - isErrorMessage: stream == "stderr")); + launcher = _guestRuntime.CreateDefaultLauncher(); } // Start guest apphost - it will connect to AppHost server, define resources. @@ -528,7 +525,7 @@ await GenerateCodeViaRpcAsync( _logger.LogError("{Language} apphost exited with code {ExitCode}", DisplayName, guestExitCode); // Display the output (same pattern as DotNetCliRunner) - if (guestOutput is { HasLiveOutputCallback: false }) + if (guestOutput is not null) { _interactionService.DisplayLines(guestOutput.GetLines()); } diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index 71c09f7886e..9884b9972a9 100644 --- a/src/Aspire.Cli/Projects/GuestRuntime.cs +++ b/src/Aspire.Cli/Projects/GuestRuntime.cs @@ -151,7 +151,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func? com /// /// Creates the default process-based launcher for this runtime. /// - public ProcessGuestLauncher CreateDefaultLauncher(Action? liveOutputCallback = null) => new(_spec.Language, _logger, _commandResolver, liveOutputCallback); + public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _commandResolver); /// /// Replaces placeholders in command arguments with actual values. diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 9d6de7bc190..44911833b00 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -15,14 +15,12 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher private readonly string _language; private readonly ILogger _logger; private readonly Func _commandResolver; - private readonly Action? _liveOutputCallback; - public ProcessGuestLauncher(string language, ILogger logger, Func? commandResolver = null, Action? liveOutputCallback = null) + public ProcessGuestLauncher(string language, ILogger logger, Func? commandResolver = null) { _language = language; _logger = logger; _commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath; - _liveOutputCallback = liveOutputCallback; } public async Task<(int ExitCode, OutputCollector? Output)> LaunchAsync( @@ -64,17 +62,11 @@ public ProcessGuestLauncher(string language, ILogger logger, Func { - if (e.Data is null) - { - stdoutCompleted.TrySetResult(); - } - else + if (e.Data is not null) { _logger.LogDebug("{Language}({ProcessId}) stdout: {Line}", _language, process.Id, e.Data); outputCollector.AppendOutput(e.Data); @@ -83,11 +75,7 @@ public ProcessGuestLauncher(string language, ILogger logger, Func { - if (e.Data is null) - { - stderrCompleted.TrySetResult(); - } - else + if (e.Data is not null) { _logger.LogDebug("{Language}({ProcessId}) stderr: {Line}", _language, process.Id, e.Data); outputCollector.AppendError(e.Data); @@ -99,7 +87,6 @@ public ProcessGuestLauncher(string language, ILogger logger, Func? _liveOutputCallback; /// /// Creates an OutputCollector that only buffers output in memory. @@ -26,42 +25,35 @@ public OutputCollector() : this(null, "AppHost") /// /// Optional file logger for writing output to disk. /// Category for log entries (e.g., "Build", "AppHost"). - /// Optional callback invoked immediately when a line is appended. - public OutputCollector(FileLoggerProvider? fileLogger, string category = "AppHost", Action? liveOutputCallback = null) + public OutputCollector(FileLoggerProvider? fileLogger, string category = "AppHost") { _fileLogger = fileLogger; _category = category; - _liveOutputCallback = liveOutputCallback; } - public bool HasLiveOutputCallback => _liveOutputCallback is not null; - public void AppendOutput(string line) { - AppendLine("stdout", line); + lock (_lock) + { + _lines.Add(("stdout", line)); + _fileLogger?.WriteLog(DateTimeOffset.UtcNow, LogLevel.Information, _category, line); + } } public void AppendError(string line) - { - AppendLine("stderr", line); - } - - public IEnumerable<(string Stream, string Line)> GetLines() { lock (_lock) { - return _lines.ToArray(); + _lines.Add(("stderr", line)); + _fileLogger?.WriteLog(DateTimeOffset.UtcNow, LogLevel.Error, _category, line); } } - private void AppendLine(string stream, string line) + public IEnumerable<(string Stream, string Line)> GetLines() { lock (_lock) { - _lines.Add((stream, line)); - _fileLogger?.WriteLog(DateTimeOffset.UtcNow, stream == "stderr" ? LogLevel.Error : LogLevel.Information, _category, line); + return _lines.ToArray(); } - - _liveOutputCallback?.Invoke(stream, line); } -} +} \ No newline at end of file diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AppHostConsoleLogTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AppHostConsoleLogTests.cs deleted file mode 100644 index 7b363c0d34a..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/AppHostConsoleLogTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Tests.Utils; -using Hex1b.Automation; -using Hex1b.Input; -using Xunit; - -namespace Aspire.Cli.EndToEnd.Tests; - -/// -/// End-to-end tests ensuring apphost console output is visible during non-detached aspire run. -/// -public sealed class AppHostConsoleLogTests(ITestOutputHelper output) -{ - [Fact] - public async Task Run_ShowsDotNetAppHostConsoleOutput() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); - - await auto.AspireInitAsync(counter); - - var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); - InsertAfter(appHostPath, "var builder = DistributedApplication.CreateBuilder(args);", """Console.WriteLine("Hello from dotnet apphost");"""); - - await auto.TypeAsync("aspire run --apphost apphost.cs"); - await auto.EnterAsync(); - await WaitForVisibleAppHostOutputAsync(auto, "Hello from dotnet apphost", TimeSpan.FromMinutes(3)); - - await auto.Ctrl().KeyAsync(Hex1bKey.C); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } - - [Fact] - public async Task Run_ShowsTypeScriptAppHostConsoleOutput() - { - var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); - var workspace = TemporaryWorkspace.Create(output); - - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - - var counter = new SequenceCounter(); - var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); - - await auto.TypeAsync("aspire init --language typescript --non-interactive"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); - - var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"); - InsertAfter(appHostPath, "const builder = await createBuilder();", "console.log('Hello from typescript apphost');"); - - await auto.TypeAsync("aspire run --apphost apphost.ts"); - await auto.EnterAsync(); - await WaitForVisibleAppHostOutputAsync(auto, "Hello from typescript apphost", TimeSpan.FromMinutes(3)); - - await auto.Ctrl().KeyAsync(Hex1bKey.C); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; - } - - private static void InsertAfter(string filePath, string marker, string lineToInsert) - { - var content = File.ReadAllText(filePath); - Assert.Contains(marker, content); - - var updatedContent = content.Replace( - marker, - $"{marker}{Environment.NewLine}{Environment.NewLine}{lineToInsert}", - StringComparison.Ordinal); - - File.WriteAllText(filePath, updatedContent); - } - - private static async Task WaitForVisibleAppHostOutputAsync(Hex1bTerminalAutomator auto, string expectedOutput, TimeSpan timeout) - { - await auto.WaitUntilAsync(snapshot => - { - if (snapshot.ContainsText("Select an apphost to use:")) - { - throw new InvalidOperationException("Unexpected apphost selection prompt detected."); - } - - return snapshot.ContainsText(expectedOutput); - }, timeout: timeout, description: $"waiting for apphost output '{expectedOutput}'"); - - await auto.WaitUntilTextAsync("Press CTRL+C to stop the apphost and exit.", timeout: timeout); - } -} diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 820779406d1..162b2423a8a 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -335,84 +335,6 @@ public async Task RunCommand_CompletesSuccessfully() Assert.Equal(ExitCodeConstants.Success, exitCode); } - [Fact] - public async Task RunCommand_ForwardsDotNetAppHostStdoutToConsoleLog() - { - var appHostOutputObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var interactionService = new TestInteractionService(); - var consoleMessages = new List(); - interactionService.DisplayConsoleWriteLineMessage = message => - { - consoleMessages.Add(message); - if (message.Contains("Hello from dotnet apphost", StringComparison.Ordinal)) - { - appHostOutputObserved.TrySetResult(); - } - }; - - var backchannelFactory = (IServiceProvider sp) => - { - var backchannel = new TestAppHostBackchannel - { - GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync - }; - - return backchannel; - }; - - var runnerFactory = (IServiceProvider sp) => - { - var runner = new TestDotNetCliRunner - { - BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0, - GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()) - }; - - runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => - { - var backchannel = sp.GetRequiredService(); - backchannelCompletionSource!.SetResult(backchannel); - options.StandardOutputCallback?.Invoke("Hello from dotnet apphost"); - - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, ct); - } - catch (OperationCanceledException) - { - } - - return 0; - }; - - return runner; - }; - - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.ProjectLocatorFactory = _ => new TestProjectLocator(); - options.AppHostBackchannelFactory = backchannelFactory; - options.DotNetCliRunnerFactory = runnerFactory; - options.InteractionServiceFactory = _ => interactionService; - }); - - var provider = services.BuildServiceProvider(); - var command = provider.GetRequiredService(); - var result = command.Parse("run"); - - using var cts = new CancellationTokenSource(); - var pendingRun = result.InvokeAsync(cancellationToken: cts.Token); - - await appHostOutputObserved.Task.DefaultTimeout(); - cts.Cancel(); - - var exitCode = await pendingRun.DefaultTimeout(TestConstants.LongTimeoutDuration); - - Assert.Equal(ExitCodeConstants.Success, exitCode); - Assert.Contains(consoleMessages, message => message.Contains("Hello from dotnet apphost", StringComparison.Ordinal)); - } - [Fact] public async Task RunCommand_WithNoResources_CompletesSuccessfully() { diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs deleted file mode 100644 index 6a177d21e9e..00000000000 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.Projects; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aspire.Cli.Tests.Projects; - -public class ProcessGuestLauncherTests -{ - [Fact] - public async Task LaunchAsync_ForwardsStdoutToLiveCallback() - { - // Arrange - var forwardedLines = new List<(string Stream, string Line)>(); - var launcher = new ProcessGuestLauncher( - "typescript", - NullLogger.Instance, - liveOutputCallback: (stream, line) => forwardedLines.Add((stream, line))); - - // Act - var (exitCode, output) = await launcher.LaunchAsync( - "dotnet", - ["--version"], - new DirectoryInfo(Path.GetTempPath()), - new Dictionary(), - CancellationToken.None); - - // Assert - Assert.Equal(0, exitCode); - Assert.NotNull(output); - Assert.Contains(forwardedLines, line => line.Stream == "stdout" && !string.IsNullOrWhiteSpace(line.Line)); - } -} diff --git a/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs b/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs index 7a1d23a49b8..40a54f55d0b 100644 --- a/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs @@ -102,22 +102,4 @@ public async Task OutputCollector_ConcurrentReadWrite_ShouldNotCrash() var finalLines = collector.GetLines().ToList(); Assert.Equal(100, finalLines.Count); } - - [Fact] - public void OutputCollector_LiveCallback_ReceivesStdoutAndStderr() - { - // Arrange - var forwardedLines = new List<(string Stream, string Line)>(); - var collector = new OutputCollector(fileLogger: null, liveOutputCallback: (stream, line) => forwardedLines.Add((stream, line))); - - // Act - collector.AppendOutput("hello"); - collector.AppendError("oops"); - - // Assert - Assert.True(collector.HasLiveOutputCallback); - Assert.Equal( - [("stdout", "hello"), ("stderr", "oops")], - forwardedLines); - } } diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 033459a4aee..5cc9e7b377b 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -244,36 +244,11 @@ await auto.WaitUntilAsync( await auto.DownAsync(); await auto.DownAsync(); await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("> Empty (C# AppHost)").Search(s).Count > 0 - || new CellPatternSearcher().Find("> Empty AppHost").Search(s).Count > 0, + s => new CellPatternSearcher().Find("> Empty AppHost").Search(s).Count > 0, timeout: TimeSpan.FromSeconds(5), - description: "Empty C# AppHost template selected"); + description: "Empty AppHost template selected"); await auto.EnterAsync(); - - var waitingForProjectNamePrompt = new CellPatternSearcher() - .Find("Enter the project name"); - var waitingForLanguagePrompt = new CellPatternSearcher() - .Find("Which language would you like to use?"); - var languagePromptFound = false; - - await auto.WaitUntilAsync( - s => - { - if (waitingForLanguagePrompt.Search(s).Count > 0) - { - languagePromptFound = true; - return true; - } - - return waitingForProjectNamePrompt.Search(s).Count > 0; - }, - timeout: TimeSpan.FromSeconds(10), - description: "project name or language prompt for empty apphost"); - - if (languagePromptFound) - { - await auto.EnterAsync(); // Select C# language on older CLI flows. - } + await auto.EnterAsync(); // Select C# language break; } From ad126edb143b8515cfb727f72f5950f694f99859 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 17 Mar 2026 12:57:28 +0800 Subject: [PATCH 2/4] Log guest app host console output to CLI log file - Pass FileLoggerProvider through GuestAppHostProject -> GuestRuntime -> ProcessGuestLauncher -> OutputCollector so guest process stdout/stderr is written to the CLI log file (matching .NET app host behavior) - Add OutputLineStream enum to replace stringly-typed stdout/stderr stream identifiers - Add ConsoleLogTypes constants for console log semantic types - Add TaskCompletionSource-based stream drain with 5s timeout in ProcessGuestLauncher to ensure no trailing output lines are lost - Add test verifying guest process output is written to log file --- .../Interaction/ConsoleInteractionService.cs | 12 ++-- src/Aspire.Cli/Interaction/ConsoleLogTypes.cs | 15 +++++ .../ExtensionInteractionService.cs | 6 +- .../Interaction/IInteractionService.cs | 5 +- .../Projects/GuestAppHostProject.cs | 6 +- src/Aspire.Cli/Projects/GuestRuntime.cs | 8 ++- .../Projects/ProcessGuestLauncher.cs | 45 ++++++++++++-- src/Aspire.Cli/Properties/launchSettings.json | 8 +++ src/Aspire.Cli/Utils/OutputCollector.cs | 27 +++++---- .../Commands/NewCommandTests.cs | 2 +- ...PublishCommandPromptingIntegrationTests.cs | 2 +- .../Commands/UpdateCommandTests.cs | 3 +- .../ConsoleInteractionServiceTests.cs | 4 +- .../Projects/ExtensionGuestLauncherTests.cs | 3 +- .../Projects/GuestAppHostProjectTests.cs | 4 +- .../Projects/GuestRuntimeTests.cs | 60 +++++++++++++++++-- .../Templating/DotNetTemplateFactoryTests.cs | 2 +- .../TestExtensionInteractionService.cs | 3 +- .../TestServices/TestInteractionService.cs | 3 +- .../Utils/OutputCollectorTests.cs | 6 +- 20 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 src/Aspire.Cli/Interaction/ConsoleLogTypes.cs diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 6717f757c9a..d93eb6448f6 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -286,10 +286,10 @@ public void WriteConsoleLog(string message, int? lineNumber = null, string? type var style = isErrorMessage ? s_errorMessageStyle : type switch { - "waiting" => s_waitingMessageStyle, - "running" => s_infoMessageStyle, - "exitCode" => s_exitCodeMessageStyle, - "failedToStart" => s_errorMessageStyle, + ConsoleLogTypes.Waiting => s_waitingMessageStyle, + ConsoleLogTypes.Running => s_infoMessageStyle, + ConsoleLogTypes.ExitCode => s_exitCodeMessageStyle, + ConsoleLogTypes.FailedToStart => s_errorMessageStyle, _ => s_infoMessageStyle }; @@ -302,11 +302,11 @@ public void DisplaySuccess(string message, bool allowMarkup = false) DisplayMessage(KnownEmojis.CheckMark, message, allowMarkup); } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { foreach (var (stream, line) in lines) { - if (stream == "stdout") + if (stream == OutputLineStream.StdOut) { MessageConsole.MarkupLineInterpolated($"{line.EscapeMarkup()}"); } diff --git a/src/Aspire.Cli/Interaction/ConsoleLogTypes.cs b/src/Aspire.Cli/Interaction/ConsoleLogTypes.cs new file mode 100644 index 00000000000..d89093eedb6 --- /dev/null +++ b/src/Aspire.Cli/Interaction/ConsoleLogTypes.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Interaction; + +/// +/// Known semantic types for console log messages emitted directly by the CLI. +/// +internal static class ConsoleLogTypes +{ + public const string Waiting = "waiting"; + public const string Running = "running"; + public const string ExitCode = "exitCode"; + public const string FailedToStart = "failedToStart"; +} diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index e26db83e942..c6060ec1496 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -342,9 +342,11 @@ public void DisplayDashboardUrls(DashboardUrlsState dashboardUrls) Debug.Assert(result); } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { - var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayLinesAsync(lines.Select(line => new DisplayLineState(line.Stream.RemoveSpectreFormatting(), line.Line.RemoveSpectreFormatting())), _cancellationToken)); + var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayLinesAsync(lines.Select(line => new DisplayLineState( + line.Stream == OutputLineStream.StdOut ? "stdout" : "stderr", + line.Line.RemoveSpectreFormatting())), _cancellationToken)); Debug.Assert(result); _consoleInteractionService.DisplayLines(lines); } diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index b3765c4bc59..0b2711e4adb 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Backchannel; +using Aspire.Cli.Utils; using Spectre.Console; using Spectre.Console.Rendering; @@ -25,7 +26,7 @@ internal interface IInteractionService void DisplayMarkupLine(string markup); void DisplaySuccess(string message, bool allowMarkup = false); void DisplaySubtleMessage(string message, bool allowMarkup = false); - void DisplayLines(IEnumerable<(string Stream, string Line)> lines); + void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines); void DisplayRenderable(IRenderable renderable); Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback); void DisplayCancellationMessage(); @@ -39,5 +40,7 @@ internal interface IInteractionService ConsoleOutput Console { get; set; } void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null); + // The semantic type is stringly-typed because some values originate from backchannel payloads. + // Use ConsoleLogTypes for CLI-defined values. void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false); } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 2eac5008a5f..8cbe971908b 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Certificates; using Aspire.Cli.Configuration; +using Aspire.Cli.Diagnostics; using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; @@ -40,6 +41,7 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly IFeatures _features; private readonly ILanguageDiscovery _languageDiscovery; private readonly ILogger _logger; + private readonly FileLoggerProvider _fileLoggerProvider; private readonly TimeProvider _timeProvider; private readonly RunningInstanceManager _runningInstanceManager; @@ -60,6 +62,7 @@ public GuestAppHostProject( IFeatures features, ILanguageDiscovery languageDiscovery, ILogger logger, + FileLoggerProvider fileLoggerProvider, TimeProvider? timeProvider = null) { _resolvedLanguage = language; @@ -74,6 +77,7 @@ public GuestAppHostProject( _features = features; _languageDiscovery = languageDiscovery; _logger = logger; + _fileLoggerProvider = fileLoggerProvider; _timeProvider = timeProvider ?? TimeProvider.System; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); } @@ -1275,7 +1279,7 @@ private async Task EnsureRuntimeCreatedAsync( if (_guestRuntime is null) { var runtimeSpec = await rpcClient.GetRuntimeSpecAsync(_resolvedLanguage.LanguageId, cancellationToken); - _guestRuntime = new GuestRuntime(runtimeSpec, _logger); + _guestRuntime = new GuestRuntime(runtimeSpec, _logger, _fileLoggerProvider); _logger.LogDebug("Created GuestRuntime for {Language}: Execute={Command} {Args}", _resolvedLanguage.LanguageId, diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index 9884b9972a9..8038acf25e6 100644 --- a/src/Aspire.Cli/Projects/GuestRuntime.cs +++ b/src/Aspire.Cli/Projects/GuestRuntime.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Diagnostics; using Aspire.Cli.Utils; using Aspire.TypeSystem; using Microsoft.Extensions.Logging; @@ -15,6 +16,7 @@ internal sealed class GuestRuntime { private readonly RuntimeSpec _spec; private readonly ILogger _logger; + private readonly FileLoggerProvider? _fileLoggerProvider; private readonly Func _commandResolver; /// @@ -22,11 +24,13 @@ internal sealed class GuestRuntime /// /// The runtime specification describing how to execute the guest language. /// Logger for debugging output. + /// Optional file logger for writing output to disk. /// Optional command resolver used to locate executables on PATH. - public GuestRuntime(RuntimeSpec spec, ILogger logger, Func? commandResolver = null) + public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func? commandResolver = null) { _spec = spec; _logger = logger; + _fileLoggerProvider = fileLoggerProvider; _commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath; } @@ -151,7 +155,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func? com /// /// Creates the default process-based launcher for this runtime. /// - public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _commandResolver); + public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _fileLoggerProvider, _commandResolver); /// /// Replaces placeholders in command arguments with actual values. diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 44911833b00..6579b1fb8ad 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Cli.Diagnostics; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -14,12 +15,14 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher { private readonly string _language; private readonly ILogger _logger; + private readonly FileLoggerProvider? _fileLoggerProvider; private readonly Func _commandResolver; - public ProcessGuestLauncher(string language, ILogger logger, Func? commandResolver = null) + public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func? commandResolver = null) { _language = language; _logger = logger; + _fileLoggerProvider = fileLoggerProvider; _commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath; } @@ -62,11 +65,18 @@ public ProcessGuestLauncher(string language, ILogger logger, Func { - if (e.Data is not null) + if (e.Data is null) + { + // ProcessDataReceivedEventArgs.Data is null when the redirected stdout stream closes. + stdoutCompleted.TrySetResult(); + } + else { _logger.LogDebug("{Language}({ProcessId}) stdout: {Line}", _language, process.Id, e.Data); outputCollector.AppendOutput(e.Data); @@ -75,7 +85,12 @@ public ProcessGuestLauncher(string language, ILogger logger, Func { - if (e.Data is not null) + if (e.Data is null) + { + // ProcessDataReceivedEventArgs.Data is null when the redirected stderr stream closes. + stderrCompleted.TrySetResult(); + } + else { _logger.LogDebug("{Language}({ProcessId}) stderr: {Line}", _language, process.Id, e.Data); outputCollector.AppendError(e.Data); @@ -87,6 +102,28 @@ public ProcessGuestLauncher(string language, ILogger logger, Func WaitForDrainAsync(Task drainTask, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + await drainTask.WaitAsync(timeoutCts.Token); + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } + } } diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json index b4af2b2902c..93d5b73ccb4 100644 --- a/src/Aspire.Cli/Properties/launchSettings.json +++ b/src/Aspire.Cli/Properties/launchSettings.json @@ -87,6 +87,14 @@ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" } }, + "run-typescriptapphost": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "run --isolated -d --project ../../../../../playground/TypeScriptAppHost/apphost.ts", + "environmentVariables": { + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, "get-resources": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/src/Aspire.Cli/Utils/OutputCollector.cs b/src/Aspire.Cli/Utils/OutputCollector.cs index 67568235995..abfd33c0f96 100644 --- a/src/Aspire.Cli/Utils/OutputCollector.cs +++ b/src/Aspire.Cli/Utils/OutputCollector.cs @@ -6,9 +6,15 @@ namespace Aspire.Cli.Utils; +internal enum OutputLineStream +{ + StdOut, + StdErr +} + internal sealed class OutputCollector { - private readonly CircularBuffer<(string Stream, string Line)> _lines = new(10000); // 10k lines. + private readonly CircularBuffer<(OutputLineStream Stream, string Line)> _lines = new(10000); // 10k lines. private readonly object _lock = new object(); private readonly FileLoggerProvider? _fileLogger; private readonly string _category; @@ -33,27 +39,28 @@ public OutputCollector(FileLoggerProvider? fileLogger, string category = "AppHos public void AppendOutput(string line) { - lock (_lock) - { - _lines.Add(("stdout", line)); - _fileLogger?.WriteLog(DateTimeOffset.UtcNow, LogLevel.Information, _category, line); - } + AppendLine(OutputLineStream.StdOut, line); } public void AppendError(string line) + { + AppendLine(OutputLineStream.StdErr, line); + } + + public IEnumerable<(OutputLineStream Stream, string Line)> GetLines() { lock (_lock) { - _lines.Add(("stderr", line)); - _fileLogger?.WriteLog(DateTimeOffset.UtcNow, LogLevel.Error, _category, line); + return _lines.ToArray(); } } - public IEnumerable<(string Stream, string Line)> GetLines() + private void AppendLine(OutputLineStream stream, string line) { lock (_lock) { - return _lines.ToArray(); + _lines.Add((stream, line)); + _fileLogger?.WriteLog(DateTimeOffset.UtcNow, stream == OutputLineStream.StdErr ? LogLevel.Error : LogLevel.Information, _category, line); } } } \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index ca386eee71e..d671a48055d 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1574,7 +1574,7 @@ public Task> PromptForSelectionsAsync(string promptText, IEn public void DisplayError(string errorMessage) { } public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } public void DisplaySuccess(string message, bool allowMarkup = false) { } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { } public void DisplayCancellationMessage() { } public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 4e10044b760..a3e046d8e77 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -953,7 +953,7 @@ public Task ConfirmAsync(string promptText, bool defaultValue = true, Canc public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } public void DisplaySuccess(string message, bool allowMarkup = false) { } public void DisplaySubtleMessage(string message, bool allowMarkup = false) { } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { } public void DisplayCancellationMessage() { } public void DisplayEmptyLine() { } public void DisplayPlainText(string text) { } diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 3b8e52d32f4..6c8d9d1acc5 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; using Spectre.Console.Rendering; @@ -1062,7 +1063,7 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri public void DisplayMarkupLine(string markup) => _innerService.DisplayMarkupLine(markup); public void DisplaySuccess(string message, bool allowMarkup = false) => _innerService.DisplaySuccess(message, allowMarkup); public void DisplaySubtleMessage(string message, bool allowMarkup = false) => _innerService.DisplaySubtleMessage(message, allowMarkup); - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) => _innerService.DisplayLines(lines); + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) => _innerService.DisplayLines(lines); public void DisplayCancellationMessage() { OnCancellationMessageDisplayed?.Invoke(); diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 72055c4232f..50d85907190 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -112,8 +112,8 @@ public void DisplayLines_WithMarkupCharacters_DoesNotCauseMarkupParsingError() var interactionService = CreateInteractionService(console, executionContext); var lines = new[] { - ("stdout", "Command output with brackets"), - ("stderr", "Error output with [square] brackets") + (OutputLineStream.StdOut, "Command output with brackets"), + (OutputLineStream.StdErr, "Error output with [square] brackets") }; // Act - this should not throw an exception due to markup parsing diff --git a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs index dace11a8043..52670fb8571 100644 --- a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; +using Aspire.Cli.Utils; namespace Aspire.Cli.Tests.Projects; @@ -165,7 +166,7 @@ public Task LaunchAppHostAsync(string projectFile, List arguments, List< public void DisplayError(string errorMessage) => throw new NotImplementedException(); public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) => throw new NotImplementedException(); public void DisplaySuccess(string message, bool allowMarkup = false) => throw new NotImplementedException(); - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) => throw new NotImplementedException(); + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) => throw new NotImplementedException(); public void DisplayCancellationMessage() => throw new NotImplementedException(); public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public void DisplaySubtleMessage(string message, bool allowMarkup = false) => throw new NotImplementedException(); diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index aa913c20326..a72bc3547ec 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Configuration; +using Aspire.Cli.Diagnostics; using Aspire.Cli.Projects; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; @@ -362,6 +363,7 @@ private static GuestAppHostProject CreateGuestAppHostProject(DirectoryInfo works configurationService: configService, features: new Features(configuration, NullLogger.Instance), languageDiscovery: new TestLanguageDiscovery(), - logger: NullLogger.Instance); + logger: NullLogger.Instance, + fileLoggerProvider: new FileLoggerProvider(Path.Combine(Path.GetTempPath(), $"test-guest-{Guid.NewGuid()}.log"), new TestStartupErrorWriter())); } } diff --git a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs index 973377b17d0..c6b147b400b 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Diagnostics; using Aspire.Cli.Projects; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Utils; using Aspire.TypeSystem; using Microsoft.Extensions.Logging.Abstractions; @@ -310,7 +312,7 @@ public async Task InstallDependenciesAsync_WhenNpmIsMissing_ReturnsNodeInstallMe InstallDependencies = new CommandSpec { Command = "npm", Args = ["install"] } }, NullLogger.Instance, - _ => null); + commandResolver: _ => null); var (exitCode, output) = await runtime.InstallDependenciesAsync(new DirectoryInfo(Path.GetTempPath()), CancellationToken.None); @@ -319,7 +321,7 @@ public async Task InstallDependenciesAsync_WhenNpmIsMissing_ReturnsNodeInstallMe output.GetLines(), line => { - Assert.Equal("stderr", line.Stream); + Assert.Equal(OutputLineStream.StdErr, line.Stream); Assert.Equal("npm is not installed or not found in PATH. Please install Node.js and try again.", line.Line); }); } @@ -337,7 +339,7 @@ public async Task RunAsync_WhenNpxIsMissing_ReturnsNodeInstallMessage() Execute = new CommandSpec { Command = "npx", Args = ["tsx", "{appHostFile}"] } }, NullLogger.Instance, - _ => null); + commandResolver: _ => null); var appHostFile = new FileInfo(Path.Combine(Path.GetTempPath(), "apphost.ts")); var (exitCode, output) = await runtime.RunAsync( @@ -354,11 +356,61 @@ public async Task RunAsync_WhenNpxIsMissing_ReturnsNodeInstallMessage() resolvedOutput.GetLines(), line => { - Assert.Equal("stderr", line.Stream); + Assert.Equal(OutputLineStream.StdErr, line.Stream); Assert.Equal("npx is not installed or not found in PATH. Please install Node.js and try again.", line.Line); }); } + [Fact] + public async Task ProcessGuestLauncher_WritesOutputToLogFile() + { + var logFilePath = Path.Combine(Path.GetTempPath(), $"guest-output-test-{Guid.NewGuid()}.log"); + + try + { + using var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()); + + var launcher = new ProcessGuestLauncher( + "test", + NullLogger.Instance, + fileLoggerProvider, + commandResolver: cmd => cmd == "dotnet" ? "dotnet" : null); + + var (exitCode, output) = await launcher.LaunchAsync( + "dotnet", + ["--version"], + new DirectoryInfo(Path.GetTempPath()), + new Dictionary(), + CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.NotNull(output); + + // OutputCollector should have captured stdout + var lines = output.GetLines().ToArray(); + Assert.NotEmpty(lines); + + // Dispose the provider to flush all pending writes + fileLoggerProvider.Dispose(); + + // Verify the log file was written and contains the output + Assert.True(File.Exists(logFilePath), "Log file should exist"); + var logContents = await File.ReadAllTextAsync(logFilePath); + Assert.Contains("[AppHost]", logContents); + + // The dotnet --version output should appear in the log + var stdoutLine = lines.First(l => l.Stream == OutputLineStream.StdOut); + Assert.Contains(stdoutLine.Line, logContents); + } + finally + { + if (File.Exists(logFilePath)) + { + File.Delete(logFilePath); + } + } + } + private sealed class RecordingLauncher : IGuestProcessLauncher { public string LastCommand { get; private set; } = string.Empty; diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 7433f9d9510..3bf653368cc 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -481,7 +481,7 @@ public void ShowStatus(string message, Action work, KnownEmoji? emoji = null, bo public void DisplaySuccess(string message, bool allowMarkup = false) { } public void DisplayError(string message) { } public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { } public void DisplayCancellationMessage() { } public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; public void DisplayPlainText(string text) { } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index aa3bc81bc9b..a27e9ae7c6c 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; +using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -112,7 +113,7 @@ public void WriteDebugSessionMessage(string message, bool stdout, string? textSt { } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index 7bf0f3c190e..befce116ddf 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -4,6 +4,7 @@ using System.Collections; using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; +using Aspire.Cli.Utils; using Spectre.Console; using Spectre.Console.Rendering; @@ -153,7 +154,7 @@ public void DisplaySuccess(string message, bool allowMarkup = false) { } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) + public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { } diff --git a/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs b/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs index 40a54f55d0b..8f5b96955ac 100644 --- a/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs @@ -44,8 +44,8 @@ public async Task OutputCollector_ThreadSafety_MultipleThreadsAddingLines() Assert.Equal(threadCount * linesPerThread, lines.Count); // Check that we have both stdout and stderr entries - var stdoutLines = lines.Where(l => l.Stream == "stdout").ToList(); - var stderrLines = lines.Where(l => l.Stream == "stderr").ToList(); + var stdoutLines = lines.Where(l => l.Stream == OutputLineStream.StdOut).ToList(); + var stderrLines = lines.Where(l => l.Stream == OutputLineStream.StdErr).ToList(); Assert.Equal(threadCount * linesPerThread / 2, stdoutLines.Count); Assert.Equal(threadCount * linesPerThread / 2, stderrLines.Count); @@ -65,7 +65,7 @@ public void OutputCollector_GetLines_ReturnsSnapshotNotLiveReference() // Assert - Snapshot should not be affected by subsequent additions Assert.Single(snapshot); Assert.Equal("initial line", snapshot[0].Line); - Assert.Equal("stdout", snapshot[0].Stream); + Assert.Equal(OutputLineStream.StdOut, snapshot[0].Stream); // New call should include the additional line var newSnapshot = collector.GetLines().ToList(); From 03b4d24a701c64b808578182e915b4e203ad492c Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 17 Mar 2026 13:05:50 +0800 Subject: [PATCH 3/4] Fix missing EOF newline, remove local-only launch profile, clean up test FileLoggerProvider --- src/Aspire.Cli/Properties/launchSettings.json | 8 -------- src/Aspire.Cli/Utils/OutputCollector.cs | 2 +- .../Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs | 4 +++- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json index 93d5b73ccb4..b4af2b2902c 100644 --- a/src/Aspire.Cli/Properties/launchSettings.json +++ b/src/Aspire.Cli/Properties/launchSettings.json @@ -87,14 +87,6 @@ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" } }, - "run-typescriptapphost": { - "commandName": "Project", - "dotnetRunMessages": true, - "commandLineArgs": "run --isolated -d --project ../../../../../playground/TypeScriptAppHost/apphost.ts", - "environmentVariables": { - "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" - } - }, "get-resources": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/src/Aspire.Cli/Utils/OutputCollector.cs b/src/Aspire.Cli/Utils/OutputCollector.cs index abfd33c0f96..cd53f436e10 100644 --- a/src/Aspire.Cli/Utils/OutputCollector.cs +++ b/src/Aspire.Cli/Utils/OutputCollector.cs @@ -63,4 +63,4 @@ private void AppendLine(OutputLineStream stream, string line) _fileLogger?.WriteLog(DateTimeOffset.UtcNow, stream == OutputLineStream.StdErr ? LogLevel.Error : LogLevel.Information, _category, line); } } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index a72bc3547ec..20f5f6f9765 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -351,6 +351,8 @@ private static GuestAppHostProject CreateGuestAppHostProject(DirectoryInfo works var configuration = new ConfigurationBuilder().Build(); + var logFilePath = Path.Combine(Path.GetTempPath(), $"test-guest-{Guid.NewGuid()}.log"); + return new GuestAppHostProject( language: language, interactionService: new TestInteractionService(), @@ -364,6 +366,6 @@ private static GuestAppHostProject CreateGuestAppHostProject(DirectoryInfo works features: new Features(configuration, NullLogger.Instance), languageDiscovery: new TestLanguageDiscovery(), logger: NullLogger.Instance, - fileLoggerProvider: new FileLoggerProvider(Path.Combine(Path.GetTempPath(), $"test-guest-{Guid.NewGuid()}.log"), new TestStartupErrorWriter())); + fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter())); } } From 725c7d40fb905e51e3b146429e68c6778c70d725 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 17 Mar 2026 13:54:16 +0800 Subject: [PATCH 4/4] Fix tests --- tests/Shared/Hex1bAutomatorTestHelpers.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 5cc9e7b377b..08d09b8a6dc 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -244,11 +244,10 @@ await auto.WaitUntilAsync( await auto.DownAsync(); await auto.DownAsync(); await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("> Empty AppHost").Search(s).Count > 0, + s => new CellPatternSearcher().Find("> Empty (C# AppHost)").Search(s).Count > 0, timeout: TimeSpan.FromSeconds(5), description: "Empty AppHost template selected"); await auto.EnterAsync(); - await auto.EnterAsync(); // Select C# language break; }