diff --git a/playground/python/Python.AppHost/Program.cs b/playground/python/Python.AppHost/Program.cs index 3f0b16ee901..d301b3e0b39 100644 --- a/playground/python/Python.AppHost/Program.cs +++ b/playground/python/Python.AppHost/Program.cs @@ -3,22 +3,22 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.AddPythonApp("script-only", "../script_only", "main.py"); +builder.AddPythonApp("scriptonly", "../script_only", "main.py"); builder.AddPythonApp("instrumented-script", "../instrumented_script", "main.py"); -builder.AddPythonModule("fastapi-app", "../module_only", "uvicorn") +builder.AddPythonModule("fastapiapp", "../module_only", "uvicorn") .WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8000") .WithHttpEndpoint(targetPort: 8000) .WithUv(); // Run the same app on another port using uvicorn directly -builder.AddPythonExecutable("fastapi-uvicorn-app", "../module_only", "uvicorn") +builder.AddPythonExecutable("fastapiuvicornapp", "../module_only", "uvicorn") .WithDebugging() .WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8001") .WithHttpEndpoint(targetPort: 8001); // Flask app using Flask module directly -builder.AddPythonModule("flask-app", "../flask_app", "flask") +builder.AddPythonModule("flaskapp", "../flask_app", "flask") .WithEnvironment("FLASK_APP", "app:create_app") .WithArgs(c => { @@ -30,10 +30,14 @@ .WithUv(); // Uvicorn app using the AddUvicornApp method -builder.AddUvicornApp("uvicorn-app", "../uvicorn_app", "app:app") +var uvicornApp = builder.AddUvicornApp("uvicornapp", "../uvicorn_app", "app:app") .WithUv() .WithExternalHttpEndpoints(); +builder.AddPytest("uvicorn-tests", "../uvicorn_app") + .WithReference(uvicornApp).WaitFor(uvicornApp) + .WithUv(); + #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging // of the dashboard. It is not required in end developer code. Comment out this code diff --git a/playground/python/uvicorn_app/app.py b/playground/python/uvicorn_app/app.py index 10d6f8372e9..6927085ef33 100644 --- a/playground/python/uvicorn_app/app.py +++ b/playground/python/uvicorn_app/app.py @@ -27,6 +27,7 @@ async def root(): """Root endpoint.""" return "API service is running. Navigate to /weatherforecast to see sample data." +@app.get("/weatherforecast") async def weather_forecast(): """Weather forecast endpoint.""" # Generate fresh data if not in cache or cache unavailable. diff --git a/playground/python/uvicorn_app/pyproject.toml b/playground/python/uvicorn_app/pyproject.toml index 759b3cd4608..f56a8ff7f5e 100644 --- a/playground/python/uvicorn_app/pyproject.toml +++ b/playground/python/uvicorn_app/pyproject.toml @@ -9,3 +9,9 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-grpc>=1.38.0", "opentelemetry-instrumentation-fastapi>=0.59b0", ] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "httpx>=0.27.0", +] diff --git a/playground/python/uvicorn_app/tests/__init__.py b/playground/python/uvicorn_app/tests/__init__.py new file mode 100644 index 00000000000..888aa66c1a7 --- /dev/null +++ b/playground/python/uvicorn_app/tests/__init__.py @@ -0,0 +1,2 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. diff --git a/playground/python/uvicorn_app/tests/test_restapi.py b/playground/python/uvicorn_app/tests/test_restapi.py new file mode 100644 index 00000000000..561d2b78c84 --- /dev/null +++ b/playground/python/uvicorn_app/tests/test_restapi.py @@ -0,0 +1,46 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +"""Integration tests for the uvicorn app endpoints.""" + +import os + +import httpx +import pytest + + +@pytest.fixture +def base_url(): + """Get the base URL from environment variable.""" + url = os.environ.get("UVICORNAPP_HTTP") + if not url: + pytest.skip("UVICORNAPP_HTTP environment variable not set") + return url.rstrip("/") + + +def test_root_endpoint(base_url): + """Test the root endpoint returns expected message.""" + response = httpx.get(f"{base_url}/") + assert response.status_code == 200 + assert "API service is running" in response.text + + +def test_weatherforecast_endpoint(base_url): + """Test the weatherforecast endpoint returns valid forecast data.""" + response = httpx.get(f"{base_url}/weatherforecast") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 5 + for item in data: + assert "date" in item + assert "temperatureC" in item + assert "temperatureF" in item + assert "summary" in item + + +def test_health_endpoint(base_url): + """Test the health check endpoint returns healthy status.""" + response = httpx.get(f"{base_url}/health") + assert response.status_code == 200 + assert response.text == "Healthy" diff --git a/playground/python/uvicorn_app/uv.lock b/playground/python/uvicorn_app/uv.lock index 6aa77515380..c8294ff18c9 100644 --- a/playground/python/uvicorn_app/uv.lock +++ b/playground/python/uvicorn_app/uv.lock @@ -168,6 +168,12 @@ dependencies = [ { name = "opentelemetry-instrumentation-fastapi" }, ] +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.119.0" }, @@ -176,6 +182,12 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.59b0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.0.0" }, +] + [[package]] name = "googleapis-common-protos" version = "1.71.0" @@ -299,6 +311,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -545,6 +566,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "protobuf" version = "6.33.0" @@ -638,6 +668,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannel.cs new file mode 100644 index 00000000000..e2309079ff8 --- /dev/null +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannel.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using StreamJsonRpc; + +namespace Aspire.Cli.Backchannel; + +/// +/// Encapsulates communication with an AppHost via the auxiliary backchannel. +/// +internal sealed class AuxiliaryBackchannel : IAuxiliaryBackchannel +{ + private readonly JsonRpc _rpc; + + /// + /// Initializes a new instance of the class. + /// + /// The JSON-RPC connection to the AppHost. + public AuxiliaryBackchannel(JsonRpc rpc) + { + _rpc = rpc ?? throw new ArgumentNullException(nameof(rpc)); + } + + /// + public Task GetAppHostInformationAsync(CancellationToken cancellationToken = default) + { + return _rpc.InvokeWithCancellationAsync("GetAppHostInformationAsync", cancellationToken: cancellationToken); + } + + /// + public Task GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default) + { + return _rpc.InvokeWithCancellationAsync("GetDashboardMcpConnectionInfoAsync", cancellationToken: cancellationToken); + } + + /// + public Task GetTestResultsAsync(CancellationToken cancellationToken = default) + { + return _rpc.InvokeWithCancellationAsync("GetTestResultsAsync", cancellationToken: cancellationToken); + } + + /// + public Task StopAppHostAsync(CancellationToken cancellationToken = default) + { + return _rpc.InvokeWithCancellationAsync("StopAppHostAsync", cancellationToken: cancellationToken); + } +} diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index 37b56596bed..bfd8631d268 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -185,16 +185,19 @@ private async Task TryConnectToSocketAsync(string socketPath, CancellationToken var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter())); rpc.StartListening(); + // Create the auxiliary backchannel wrapper + var backchannel = new AuxiliaryBackchannel(rpc); + // Get the AppHost information - var appHostInfo = await rpc.InvokeAsync("GetAppHostInformationAsync").ConfigureAwait(false); + var appHostInfo = await backchannel.GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false); // Get the MCP connection info - var mcpInfo = await rpc.InvokeAsync("GetDashboardMcpConnectionInfoAsync").ConfigureAwait(false); + var mcpInfo = await backchannel.GetDashboardMcpConnectionInfoAsync(cancellationToken).ConfigureAwait(false); // Determine if this AppHost is in scope of the MCP server's working directory var isInScope = IsAppHostInScope(appHostInfo?.AppHostPath); - var connection = new AppHostConnection(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope); + var connection = new AppHostConnection(hash, socketPath, rpc, backchannel, mcpInfo, appHostInfo, isInScope); // Set up disconnect handler rpc.Disconnected += (sender, args) => @@ -320,11 +323,12 @@ internal sealed class AppHostConnection /// /// Initializes a new instance of the class. /// - public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardMcpConnectionInfo? mcpInfo, AppHostInformation? appHostInfo, bool isInScope) + public AppHostConnection(string hash, string socketPath, JsonRpc rpc, IAuxiliaryBackchannel backchannel, DashboardMcpConnectionInfo? mcpInfo, AppHostInformation? appHostInfo, bool isInScope) { Hash = hash; SocketPath = socketPath; Rpc = rpc; + Backchannel = backchannel; McpInfo = mcpInfo; AppHostInfo = appHostInfo; IsInScope = isInScope; @@ -346,6 +350,11 @@ public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardM /// public JsonRpc Rpc { get; } + /// + /// Gets the auxiliary backchannel for communicating with the AppHost. + /// + public IAuxiliaryBackchannel Backchannel { get; } + /// /// Gets the MCP connection information for the Dashboard. /// diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 7412289f3e0..2031ecf04eb 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -35,6 +35,9 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(AppHostProjectSearchResultPoco))] [JsonSerializable(typeof(DashboardMcpConnectionInfo))] [JsonSerializable(typeof(AppHostInformation))] +[JsonSerializable(typeof(TestResults))] +[JsonSerializable(typeof(TestResourceResult))] +[JsonSerializable(typeof(TestResultFileInfo))] internal partial class BackchannelJsonSerializerContext : JsonSerializerContext { [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")] diff --git a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannel.cs new file mode 100644 index 00000000000..bb3eb7cf7cf --- /dev/null +++ b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannel.cs @@ -0,0 +1,38 @@ +// 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.Backchannel; + +/// +/// Interface for communicating with an AppHost via the auxiliary backchannel. +/// +internal interface IAuxiliaryBackchannel +{ + /// + /// Gets information about the AppHost. + /// + /// A cancellation token. + /// The AppHost information including the fully qualified path and process ID. + Task GetAppHostInformationAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the Dashboard MCP connection information including endpoint URL and API token. + /// + /// A cancellation token. + /// The MCP connection information, or null if the dashboard is not part of the application model. + Task GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the test results by waiting for all test resources to complete. + /// + /// A cancellation token. + /// The test results. + Task GetTestResultsAsync(CancellationToken cancellationToken = default); + + /// + /// Initiates an orderly shutdown of the AppHost. + /// + /// A cancellation token. + /// A task that completes when the shutdown request has been sent. + Task StopAppHostAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 6a85abe96f2..178a09acd82 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -32,6 +32,7 @@ public RootCommand( ExecCommand execCommand, UpdateCommand updateCommand, McpCommand mcpCommand, + TestCommand testCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, IInteractionService interactionService) @@ -49,6 +50,7 @@ public RootCommand( ArgumentNullException.ThrowIfNull(updateCommand); ArgumentNullException.ThrowIfNull(execCommand); ArgumentNullException.ThrowIfNull(mcpCommand); + ArgumentNullException.ThrowIfNull(testCommand); ArgumentNullException.ThrowIfNull(extensionInternalCommand); ArgumentNullException.ThrowIfNull(featureFlags); ArgumentNullException.ThrowIfNull(interactionService); @@ -122,5 +124,10 @@ public RootCommand( Subcommands.Add(execCommand); } + if (featureFlags.IsFeatureEnabled(KnownFeatures.TestCommandEnabled, false)) + { + Subcommands.Add(testCommand); + } + } } diff --git a/src/Aspire.Cli/Commands/TestCommand.cs b/src/Aspire.Cli/Commands/TestCommand.cs new file mode 100644 index 00000000000..7eae1b3a87d --- /dev/null +++ b/src/Aspire.Cli/Commands/TestCommand.cs @@ -0,0 +1,209 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.DotNet; +using Aspire.Cli.Interaction; +using Aspire.Cli.Projects; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Spectre.Console; +using StreamJsonRpc; + +namespace Aspire.Cli.Commands; + +internal class TestCommand : BaseCommand +{ + private readonly IDotNetCliRunner _runner; + private readonly IProjectLocator _projectLocator; + private readonly AspireCliTelemetry _telemetry; + + public TestCommand( + IDotNetCliRunner runner, + IProjectLocator projectLocator, + IInteractionService interactionService, + AspireCliTelemetry telemetry, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext) + : base("test", TestCommandStrings.Description, features, updateNotifier, executionContext, interactionService) + { + ArgumentNullException.ThrowIfNull(runner); + ArgumentNullException.ThrowIfNull(projectLocator); + ArgumentNullException.ThrowIfNull(interactionService); + ArgumentNullException.ThrowIfNull(telemetry); + ArgumentNullException.ThrowIfNull(features); + + _runner = runner; + _projectLocator = projectLocator; + _telemetry = telemetry; + + var projectOption = new Option("--project"); + projectOption.Description = TestCommandStrings.ProjectArgumentDescription; + Options.Add(projectOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var passedAppHostProjectFile = parseResult.GetValue("--project"); + + var runOutputCollector = new OutputCollector(); + + try + { + using var activity = _telemetry.ActivitySource.StartActivity(this.Name); + + var effectiveAppHostFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, createSettingsFile: false, cancellationToken); + + if (effectiveAppHostFile is null) + { + return ExitCodeConstants.FailedToFindProject; + } + + var runOptions = new DotNetCliRunnerInvocationOptions + { + StandardOutputCallback = runOutputCollector.AppendOutput, + StandardErrorCallback = runOutputCollector.AppendError, + }; + + var backchannelCompletitionSource = new TaskCompletionSource(); + + var env = new Dictionary(); + + var pendingRun = _runner.RunAsync( + effectiveAppHostFile, + watch: false, + noBuild: false, + Array.Empty(), + env, + backchannelCompletitionSource, + runOptions, + cancellationToken); + + // Wait for the backchannel to be established. + var backchannel = await InteractionService.ShowStatusAsync(TestCommandStrings.ConnectingToAppHost, async () => { return await backchannelCompletitionSource.Task.WaitAsync(cancellationToken); }); + + InteractionService.DisplaySuccess(TestCommandStrings.AppHostStarted); + + // Connect to the auxiliary backchannel + var auxiliaryBackchannel = await ConnectToAuxiliaryBackchannelAsync(effectiveAppHostFile.FullName, cancellationToken); + + // Wait for test results + InteractionService.DisplayEmptyLine(); + var testResults = await InteractionService.ShowStatusAsync("Running tests...", async () => + { + return await auxiliaryBackchannel.GetTestResultsAsync(cancellationToken); + }); + + // Display test results + InteractionService.DisplayEmptyLine(); + if (testResults?.Success == true) + { + InteractionService.DisplaySuccess(testResults.Message); + } + else + { + InteractionService.DisplayError(testResults?.Message ?? "Test execution failed."); + } + + // Display links to test result files + if (testResults?.TestResourceResults is not null) + { + foreach (var resourceResult in testResults.TestResourceResults) + { + if (resourceResult.ResultFiles is { Length: > 0 }) + { + foreach (var resultFile in resourceResult.ResultFiles) + { + var filePath = resultFile.FilePath; + var fileLink = $"[link=file://{filePath.EscapeMarkup()}]{filePath.EscapeMarkup()}[/]"; + InteractionService.DisplayMessage("page_facing_up", $"Test results ({resourceResult.ResourceName}): {fileLink}"); + } + } + } + } + + // Stop the AppHost + await auxiliaryBackchannel.StopAppHostAsync(cancellationToken); + + // Wait for the apphost to exit + return await pendingRun; + } + catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) + { + InteractionService.DisplayCancellationMessage(); + return ExitCodeConstants.Success; + } + catch (ProjectLocatorException ex) + { + return HandleProjectLocatorException(ex, InteractionService); + } + catch (FailedToConnectBackchannelConnection ex) + { + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message.EscapeMarkup())); + InteractionService.DisplayLines(runOutputCollector.GetLines()); + return ExitCodeConstants.FailedToDotnetRunAppHost; + } + catch (Exception ex) + { + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message.EscapeMarkup())); + InteractionService.DisplayLines(runOutputCollector.GetLines()); + return ExitCodeConstants.FailedToDotnetRunAppHost; + } + } + + private static async Task ConnectToAuxiliaryBackchannelAsync(string appHostPath, CancellationToken cancellationToken) + { + var socketPath = GetAuxiliaryBackchannelSocketPath(appHostPath); + + // Wait for the socket to be created (with timeout) + var timeout = TimeSpan.FromSeconds(30); + var startTime = DateTime.UtcNow; + + while (!File.Exists(socketPath)) + { + if (DateTime.UtcNow - startTime > timeout) + { + throw new InvalidOperationException($"Auxiliary backchannel socket not found at {socketPath} after waiting {timeout.TotalSeconds} seconds"); + } + + await Task.Delay(100, cancellationToken); + } + + // Give the socket a moment to be ready + await Task.Delay(100, cancellationToken); + + // Connect to the Unix socket + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + var endpoint = new UnixDomainSocketEndPoint(socketPath); + + await socket.ConnectAsync(endpoint, cancellationToken); + + // Create JSON-RPC connection + var stream = new NetworkStream(socket, ownsSocket: true); + var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter())); + rpc.StartListening(); + + return new AuxiliaryBackchannel(rpc); + } + + private static string GetAuxiliaryBackchannelSocketPath(string appHostPath) + { + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var backchannelsDir = Path.Combine(homeDirectory, ".aspire", "cli", "backchannels"); + + // Compute hash from the AppHost path for consistency + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(appHostPath)); + // Use first 16 characters to keep socket path length reasonable + var hash = Convert.ToHexString(hashBytes)[..16].ToLowerInvariant(); + + return Path.Combine(backchannelsDir, $"aux.sock.{hash}"); + } +} diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 89c5acbe2fe..7f598f4fc9a 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -18,4 +18,5 @@ internal static class KnownFeatures public static string DefaultWatchEnabled => "defaultWatchEnabled"; public static string ShowAllTemplates => "showAllTemplates"; public static string DotNetSdkInstallationEnabled => "dotnetSdkInstallationEnabled"; + public static string TestCommandEnabled => "testCommandEnabled"; } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index bd763009c46..0070810fb9f 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -207,6 +207,7 @@ private static async Task BuildApplicationAsync(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Resources/TestCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TestCommandStrings.Designer.cs new file mode 100644 index 00000000000..6f7e2877c6b --- /dev/null +++ b/src/Aspire.Cli/Resources/TestCommandStrings.Designer.cs @@ -0,0 +1,99 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Aspire.Cli.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class TestCommandStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal TestCommandStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Cli.Resources.TestCommandStrings", typeof(TestCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to AppHost started. Waiting for completion.... + /// + internal static string AppHostStarted { + get { + return ResourceManager.GetString("AppHostStarted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connecting to AppHost.... + /// + internal static string ConnectingToAppHost { + get { + return ResourceManager.GetString("ConnectingToAppHost", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview). + /// + internal static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.. + /// + internal static string ProjectArgumentDescription { + get { + return ResourceManager.GetString("ProjectArgumentDescription", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/TestCommandStrings.resx b/src/Aspire.Cli/Resources/TestCommandStrings.resx new file mode 100644 index 00000000000..1f1faa73ea2 --- /dev/null +++ b/src/Aspire.Cli/Resources/TestCommandStrings.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + Connecting to AppHost... + + + AppHost started. Waiting for completion... + + diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.cs.xlf new file mode 100644 index 00000000000..f712073d96c --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.cs.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.de.xlf new file mode 100644 index 00000000000..ca702f76d93 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.de.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.es.xlf new file mode 100644 index 00000000000..97275e4cc04 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.es.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.fr.xlf new file mode 100644 index 00000000000..6df8e89e985 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.fr.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.it.xlf new file mode 100644 index 00000000000..fa181be6cb7 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.it.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ja.xlf new file mode 100644 index 00000000000..f55d8a36d1b --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ja.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ko.xlf new file mode 100644 index 00000000000..7ef7a8bdab4 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ko.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pl.xlf new file mode 100644 index 00000000000..6cbf3d0bed6 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pl.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..e483c708e7e --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pt-BR.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ru.xlf new file mode 100644 index 00000000000..6ee15553192 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ru.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.tr.xlf new file mode 100644 index 00000000000..fa12dde7b49 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.tr.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..881b9667483 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hans.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..86f406ed57c --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hant.xlf @@ -0,0 +1,27 @@ + + + + + + AppHost started. Waiting for completion... + AppHost started. Waiting for completion... + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview) + + + + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory. + + + + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 66900ffafd5..07a24c5e40f 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -140,6 +140,72 @@ public static IResourceBuilder AddPythonExecutable( this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string executableName) => AddPythonAppCore(builder, name, appDirectory, EntrypointType.Executable, executableName, DefaultVirtualEnvFolder); + /// + /// Adds a pytest test runner to the application model. + /// + /// The to add the resource to. + /// The name of the resource. + /// The path to the directory containing the Python test files. + /// A reference to the . + /// + /// + /// This method runs pytest from the virtual environment using the pytest executable. + /// The resource is automatically marked with a to indicate + /// it's a test resource that runs to completion. + /// By default, the virtual environment folder is expected to be named .venv and located in the app directory. + /// Use to specify a different virtual environment path. + /// Use WithArgs to pass arguments to pytest. + /// + /// + /// + /// Add a pytest test runner to the application model: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddPytest("api-tests", "../api") + /// .WithArgs("-v", "tests/"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddPytest( + this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory) + { + var resource = AddPythonExecutable(builder, name, appDirectory, "pytest"); + resource.WithAnnotation(new TestResourceAnnotation()); + + // Generate a unique file path for pytest report-log output + var junitxmlResultFileName = $"{Guid.NewGuid():N}.xml"; + var junitxmlFilePath = Path.Combine(Path.GetTempPath(), "aspire-test-results", junitxmlResultFileName); + + // Ensure the directory exists + Directory.CreateDirectory(Path.GetDirectoryName(junitxmlFilePath)!); + // Add the report-log argument to pytest + resource.WithArgs(context => + { + context.Args.Add($"--junitxml={junitxmlFilePath}"); + }); + + // Add the callback annotation for collecting test results + resource.WithAnnotation(new TestResultsCallbackAnnotation(async context => + { + // Wait for the test resource to reach a terminal state before collecting results + var resourceNotificationService = context.ServiceProvider.GetRequiredService(); + await resourceNotificationService.WaitForResourceAsync( + name, + targetStates: KnownResourceStates.TerminalStates, + context.CancellationToken).ConfigureAwait(false); + + var junitxmlFile = new FileInfo(junitxmlFilePath); + if (junitxmlFile.Exists) + { + context.AddResultsFile(junitxmlFile, TestResultFormat.PytestReportLog); + } + })); + + return resource; + } + /// /// Adds a python application with a virtual environment to the application model. /// diff --git a/src/Aspire.Hosting/ApplicationModel/TestResourceAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/TestResourceAnnotation.cs new file mode 100644 index 00000000000..18b90f1819e --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/TestResourceAnnotation.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Marker annotation to indicate that a resource is a test resource. +/// Test resources are expected to run to completion and then exit. +/// +public sealed class TestResourceAnnotation : IResourceAnnotation +{ +} diff --git a/src/Aspire.Hosting/ApplicationModel/TestResultFormat.cs b/src/Aspire.Hosting/ApplicationModel/TestResultFormat.cs new file mode 100644 index 00000000000..82a718846e0 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/TestResultFormat.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.Hosting.ApplicationModel; + +/// +/// Specifies the format of test result files. +/// +public enum TestResultFormat +{ + /// + /// The pytest report-log format (JSON lines format with one JSON object per line). + /// + PytestReportLog +} diff --git a/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackAnnotation.cs new file mode 100644 index 00000000000..e6e2541fad9 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackAnnotation.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an annotation that provides a callback to collect test result information from a test resource. +/// +public sealed class TestResultsCallbackAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The callback to invoke to collect test results. + public TestResultsCallbackAnnotation(Func callback) + { + ArgumentNullException.ThrowIfNull(callback); + + Callback = callback; + } + + /// + /// Gets the callback to invoke to collect test results. + /// + public Func Callback { get; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackContext.cs new file mode 100644 index 00000000000..bfd23c78ccd --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackContext.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a callback context for collecting test result files from a test resource. +/// +public sealed class TestResultsCallbackContext +{ + private readonly List _resultFiles = []; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + /// A cancellation token. + public TestResultsCallbackContext(IServiceProvider serviceProvider, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + + ServiceProvider = serviceProvider; + CancellationToken = cancellationToken; + } + + /// + /// Gets the service provider. + /// + public IServiceProvider ServiceProvider { get; } + + /// + /// Gets the cancellation token. + /// + public CancellationToken CancellationToken { get; } + + /// + /// Gets the collection of test result files that have been added. + /// + public IReadOnlyList ResultFiles => _resultFiles; + + /// + /// Adds a test result file to the context. + /// + /// The file containing test results. + /// The format of the test results. + public void AddResultsFile(FileInfo file, TestResultFormat format) + { + ArgumentNullException.ThrowIfNull(file); + + _resultFiles.Add(new TestResultFile(file, format)); + } +} + +/// +/// Represents a test result file with its format. +/// +/// The file containing test results. +/// The format of the test results. +public readonly record struct TestResultFile(FileInfo File, TestResultFormat Format); diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 3526676d504..ea7706ef693 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.Dashboard; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,7 +16,8 @@ namespace Aspire.Hosting.Backchannel; /// internal sealed class AuxiliaryBackchannelRpcTarget( ILogger logger, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + IHostApplicationLifetime hostApplicationLifetime) { private const string McpEndpointName = "mcp"; @@ -126,4 +128,134 @@ public Task GetAppHostInformationAsync(CancellationToken can ApiToken = mcpApiKey }; } + + /// + /// Gets the test results by waiting for all test resources to complete. + /// + /// A cancellation token. + /// A task that completes when all test resources have reached a completed state. + public async Task GetTestResultsAsync(CancellationToken cancellationToken = default) + { + await Task.Delay(15000, cancellationToken).ConfigureAwait(false); + + var appModel = serviceProvider.GetService(); + if (appModel is null) + { + logger.LogWarning("Application model not found."); + return new TestResults { Success = false, Message = "Application model not found." }; + } + + var resourceNotificationService = serviceProvider.GetService(); + if (resourceNotificationService is null) + { + logger.LogWarning("ResourceNotificationService not found."); + return new TestResults { Success = false, Message = "ResourceNotificationService not found." }; + } + + // Find all resources with TestResourceAnnotation + var testResources = appModel.Resources + .Where(r => r.Annotations.OfType().Any()) + .ToList(); + + if (testResources.Count == 0) + { + logger.LogInformation("No test resources found in the application model."); + return new TestResults { Success = true, Message = "No test resources found." }; + } + + logger.LogInformation("Waiting for {Count} test resource(s) to complete", testResources.Count); + + // Wait for all test resources to reach a completed state and collect results + var waitTasks = testResources.Select(async resource => + { + try + { + await resourceNotificationService.WaitForResourceAsync( + resource.Name, + KnownResourceStates.Finished, + cancellationToken).ConfigureAwait(false); + + logger.LogInformation("Test resource '{ResourceName}' completed", resource.Name); + + // Invoke the test results callback if present + var callbackAnnotation = resource.Annotations.OfType().FirstOrDefault(); + TestResultFileInfo[]? resultFiles = null; + + if (callbackAnnotation is not null) + { + var context = new TestResultsCallbackContext(serviceProvider, cancellationToken); + await callbackAnnotation.Callback(context).ConfigureAwait(false); + + resultFiles = context.ResultFiles + .Select(f => new TestResultFileInfo + { + FilePath = f.File.FullName, + Format = f.Format.ToString() + }) + .ToArray(); + + logger.LogInformation("Collected {Count} result file(s) from test resource '{ResourceName}'", resultFiles.Length, resource.Name); + } + + return (resource.Name, Success: true, Error: (string?)null, ResultFiles: resultFiles); + } + catch (Exception ex) + { + logger.LogError(ex, "Error waiting for test resource '{ResourceName}'", resource.Name); + return (resource.Name, Success: false, Error: ex.Message, ResultFiles: (TestResultFileInfo[]?)null); + } + }); + + var results = await Task.WhenAll(waitTasks).ConfigureAwait(false); + + var allSuccessful = results.All(r => r.Success); + var message = allSuccessful + ? $"All {testResources.Count} test resource(s) completed successfully." + : $"Some test resources failed: {string.Join(", ", results.Where(r => !r.Success).Select(r => r.Name))}"; + + return new TestResults + { + Success = allSuccessful, + Message = message, + TestResourceResults = results.Select(r => new TestResourceResult + { + ResourceName = r.Name, + Success = r.Success, + Error = r.Error, + ResultFiles = r.ResultFiles + }).ToArray() + }; + } + + /// + /// Initiates an orderly shutdown of the AppHost. + /// + /// A cancellation token. + /// A task that completes immediately. The actual shutdown occurs after the RPC channel disconnects. + public Task StopAppHostAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; // Unused - shutdown is intentionally not cancellable once requested + + logger.LogInformation("Received request to stop AppHost. Scheduling shutdown after RPC disconnect."); + + // Fire off a background task that waits for the RPC to disconnect, then stops the application + _ = Task.Run(async () => + { + try + { + // Give the RPC response time to be sent back + await Task.Delay(500, CancellationToken.None).ConfigureAwait(false); + + logger.LogInformation("Stopping AppHost application."); + hostApplicationLifetime.StopApplication(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error stopping AppHost"); + } + }, CancellationToken.None); + + return Task.CompletedTask; + } } + diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs index 35104a43939..69e34c66277 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs @@ -123,7 +123,8 @@ await eventing.PublishAsync( // Create a new RPC target for this connection var rpcTarget = new AuxiliaryBackchannelRpcTarget( serviceProvider.GetRequiredService>(), - serviceProvider); + serviceProvider, + serviceProvider.GetRequiredService()); // Set up JSON-RPC over the client socket using var stream = new NetworkStream(clientSocket, ownsSocket: true); diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index ef5166c657b..bae09f1638f 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -287,3 +287,66 @@ internal sealed class AppHostInformation /// public int? CliProcessId { get; init; } } + +/// +/// Results from running test resources. +/// +internal sealed class TestResults +{ + /// + /// Gets or sets a value indicating whether all tests succeeded. + /// + public bool Success { get; set; } + + /// + /// Gets or sets a message describing the test results. + /// + public required string Message { get; set; } + + /// + /// Gets or sets the individual test resource results. + /// + public TestResourceResult[]? TestResourceResults { get; set; } +} + +/// +/// Result from a single test resource. +/// +internal sealed class TestResourceResult +{ + /// + /// Gets or sets the name of the test resource. + /// + public required string ResourceName { get; set; } + + /// + /// Gets or sets a value indicating whether the test succeeded. + /// + public bool Success { get; set; } + + /// + /// Gets or sets an error message if the test failed. + /// + public string? Error { get; set; } + + /// + /// Gets or sets the list of test result files produced by the resource. + /// + public TestResultFileInfo[]? ResultFiles { get; set; } +} + +/// +/// Information about a test result file. +/// +internal sealed class TestResultFileInfo +{ + /// + /// Gets or sets the full path to the test result file. + /// + public required string FilePath { get; set; } + + /// + /// Gets or sets the format of the test results. + /// + public required string Format { get; set; } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TestCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TestCommandTests.cs new file mode 100644 index 00000000000..9721eac5c1c --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TestCommandTests.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; +using RootCommand = Aspire.Cli.Commands.RootCommand; + +namespace Aspire.Cli.Tests.Commands; + +public class TestCommandTests +{ + private readonly ITestOutputHelper _outputHelper; + + public TestCommandTests(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + } + + [Fact] + public async Task TestCommandWithHelpArgumentReturnsZero() + { + using var workspace = TemporaryWorkspace.Create(_outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, _outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.TestCommandEnabled]; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var invokeConfiguration = new InvocationConfiguration(); + invokeConfiguration.Output = new TestOutputTextWriter(_outputHelper); + + var result = command.Parse("test --help"); + + var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TestCommand_WhenFeatureFlagEnabled_CommandAvailable() + { + using var workspace = TemporaryWorkspace.Create(_outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, _outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.TestCommandEnabled]; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var invokeConfiguration = new InvocationConfiguration(); + var testOutputWriter = new TestOutputTextWriter(_outputHelper); + invokeConfiguration.Output = testOutputWriter; + + var result = command.Parse("test --help"); + + var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout); + + // Should succeed because test command is registered when feature flag is enabled + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TestCommand_WhenFeatureFlagDisabled_CommandNotAvailable() + { + using var workspace = TemporaryWorkspace.Create(_outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, _outputHelper, options => + { + options.DisabledFeatures = [KnownFeatures.TestCommandEnabled]; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var invokeConfiguration = new InvocationConfiguration(); + var testOutputWriter = new TestOutputTextWriter(_outputHelper); + invokeConfiguration.Output = testOutputWriter; + + var result = command.Parse("test"); + + var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout); + + // Should fail because test command is not registered when feature flag is disabled + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TestCommand_WhenNoProjectFileFound_ReturnsFailedToFindProject() + { + using var workspace = TemporaryWorkspace.Create(_outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, _outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.TestCommandEnabled]; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var invokeConfiguration = new InvocationConfiguration(); + var testOutputWriter = new TestOutputTextWriter(_outputHelper); + invokeConfiguration.Output = testOutputWriter; + + var result = command.Parse("test"); + + var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout); + + // Should fail because no project is found + Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs index 10ace237c82..bacbb97bb4b 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs @@ -154,6 +154,7 @@ private static AppHostConnection CreateAppHostConnection(string hash, string soc { // Create a mock JsonRpc that won't be used var rpc = new JsonRpc(Stream.Null); - return new AppHostConnection(hash, socketPath, rpc, mcpInfo: null, appHostInfo, isInScope); + var backchannel = new AuxiliaryBackchannel(rpc); + return new AppHostConnection(hash, socketPath, rpc, backchannel, mcpInfo: null, appHostInfo, isInScope); } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 554d9b59a58..8a082bce5f6 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -104,6 +104,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient();