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/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..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);
}
@@ -504,10 +508,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 +529,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());
}
@@ -1278,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 71c09f7886e..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(Action? liveOutputCallback = null) => new(_spec.Language, _logger, _commandResolver, liveOutputCallback);
+ 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 9d6de7bc190..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,15 +15,15 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher
{
private readonly string _language;
private readonly ILogger _logger;
+ private readonly FileLoggerProvider? _fileLoggerProvider;
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, FileLoggerProvider? fileLoggerProvider = null, Func? commandResolver = null)
{
_language = language;
_logger = logger;
+ _fileLoggerProvider = fileLoggerProvider;
_commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath;
- _liveOutputCallback = liveOutputCallback;
}
public async Task<(int ExitCode, OutputCollector? Output)> LaunchAsync(
@@ -64,7 +65,7 @@ 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/Utils/OutputCollector.cs b/src/Aspire.Cli/Utils/OutputCollector.cs
index 6c933f39c9f..cd53f436e10 100644
--- a/src/Aspire.Cli/Utils/OutputCollector.cs
+++ b/src/Aspire.Cli/Utils/OutputCollector.cs
@@ -6,13 +6,18 @@
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;
- private readonly Action? _liveOutputCallback;
///
/// Creates an OutputCollector that only buffers output in memory.
@@ -26,27 +31,23 @@ 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);
+ AppendLine(OutputLineStream.StdOut, line);
}
public void AppendError(string line)
{
- AppendLine("stderr", line);
+ AppendLine(OutputLineStream.StdErr, line);
}
- public IEnumerable<(string Stream, string Line)> GetLines()
+ public IEnumerable<(OutputLineStream Stream, string Line)> GetLines()
{
lock (_lock)
{
@@ -54,14 +55,12 @@ public void AppendError(string line)
}
}
- private void AppendLine(string stream, string line)
+ private void AppendLine(OutputLineStream stream, string line)
{
lock (_lock)
{
_lines.Add((stream, line));
- _fileLogger?.WriteLog(DateTimeOffset.UtcNow, stream == "stderr" ? LogLevel.Error : LogLevel.Information, _category, line);
+ _fileLogger?.WriteLog(DateTimeOffset.UtcNow, stream == OutputLineStream.StdErr ? LogLevel.Error : LogLevel.Information, _category, line);
}
-
- _liveOutputCallback?.Invoke(stream, line);
}
}
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/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/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/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..20f5f6f9765 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;
@@ -350,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(),
@@ -362,6 +365,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(logFilePath, 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/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/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 7a1d23a49b8..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();
@@ -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..08d09b8a6dc 100644
--- a/tests/Shared/Hex1bAutomatorTestHelpers.cs
+++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs
@@ -244,36 +244,10 @@ 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 (C# 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.
- }
break;
}