diff --git a/extension/src/capabilities.ts b/extension/src/capabilities.ts index f1441c1e16a..3a0954165bc 100644 --- a/extension/src/capabilities.ts +++ b/extension/src/capabilities.ts @@ -12,9 +12,11 @@ export type Capability = | 'project' // Support for running C# projects | 'ms-dotnettools.csharp' // Older AppHost versions used this extension identifier instead of project | 'python' // Support for running Python projects - | 'ms-python.python'; // Older AppHost versions used this extension identifier instead of python + | 'ms-python.python' // Older AppHost versions used this extension identifier instead of python + | 'node' // Support for running Node.js/JavaScript projects (built-in to VS Code) + | 'browser'; // Support for browser debugging (built-in to VS Code via js-debug) -export type Capabilities = Capability[]; +export type Capabilities = (Capability | string)[]; function isExtensionInstalled(extensionId: string): boolean { const extension = vscode.extensions.getExtension(extensionId); @@ -34,7 +36,8 @@ export function isPythonInstalled() { } export function getSupportedCapabilities(): Capabilities { - const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'file-pickers.v1', 'build-dotnet-using-cli']; + // Node.js and browser debugging are built into VS Code via ms-vscode.js-debug, so always available + const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'file-pickers.v1', 'build-dotnet-using-cli', 'node', 'browser']; if (isCsDevKitInstalled()) { capabilities.push("devkit"); @@ -51,6 +54,11 @@ export function getSupportedCapabilities(): Capabilities { capabilities.push("ms-python.python"); } + // Also, push all extensions as capabilities + for (const extension of vscode.extensions.all) { + capabilities.push(extension.id); + } + return capabilities; } diff --git a/extension/src/dcp/AspireDcpServer.ts b/extension/src/dcp/AspireDcpServer.ts index 234bfdd6594..da3139853f3 100644 --- a/extension/src/dcp/AspireDcpServer.ts +++ b/extension/src/dcp/AspireDcpServer.ts @@ -106,9 +106,9 @@ export default class AspireDcpServer { } const launchConfig = payload.launch_configurations[0]; - const foundDebuggerExtension = debuggerExtensions.find(ext => ext.resourceType === launchConfig.type) ?? null; + const foundDebuggerExtension = debuggerExtensions.find(ext => ext.resourceType === launchConfig.type); - if (!foundDebuggerExtension) { + if (!foundDebuggerExtension && !launchConfig.debugger_properties) { const error: ErrorDetails = { code: 'UnsupportedLaunchConfiguration', message: `Unsupported launch configuration type: ${launchConfig.type}`, diff --git a/extension/src/dcp/types.ts b/extension/src/dcp/types.ts index 6061114135e..dab8072becb 100644 --- a/extension/src/dcp/types.ts +++ b/extension/src/dcp/types.ts @@ -13,9 +13,30 @@ export interface ErrorDetails { type LaunchConfigurationMode = "Debug" | "NoDebug"; +/** + * Debugger properties passed from the apphost. + * Contains DAP-standard properties (type, name, request, cwd) plus any + * IDE-specific and debug adapter-specific properties. + * @see https://microsoft.github.io/debug-adapter-protocol/specification + */ +export interface DebuggerProperties { + type: string; + name: string; + request: string; + cwd: string; + [key: string]: any; // Allow additional IDE-specific and adapter-specific properties +} + export interface ExecutableLaunchConfiguration { type: string; mode?: LaunchConfigurationMode | undefined; + /** + * Debugger-specific properties passed from the apphost. + * Contains required VS Code debug configuration properties (type, name, request, cwd) + * plus any additional adapter-specific properties. + * @see https://code.visualstudio.com/docs/debugtest/debugging-configuration + */ + debugger_properties?: DebuggerProperties; } export interface ProjectLaunchConfiguration extends ExecutableLaunchConfiguration { diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index c314c44fcb1..38b6fddca79 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -1,6 +1,6 @@ import path from "path"; import { ExecutableLaunchConfiguration, EnvVar, LaunchOptions, AspireResourceExtendedDebugConfiguration, AspireExtendedDebugConfiguration } from "../dcp/types"; -import { debugProject, runProject } from "../loc/strings"; +import { debugProject, invalidLaunchConfiguration, runProject } from "../loc/strings"; import { mergeEnvs } from "../utils/environment"; import { extensionLogOutputChannel } from "../utils/logging"; import { projectDebuggerExtension } from "./languages/dotnet"; @@ -17,31 +17,59 @@ export interface ResourceDebuggerExtension { getProjectFile: (launchConfig: ExecutableLaunchConfiguration) => string; getSupportedFileTypes: () => string[]; createDebugSessionConfigurationCallback?: (launchConfig: ExecutableLaunchConfiguration, args: string[] | undefined, env: EnvVar[], launchOptions: LaunchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration) => Promise; + isDeprecated: boolean; } -export async function createDebugSessionConfiguration(debugSessionConfig: AspireExtendedDebugConfiguration, launchConfig: ExecutableLaunchConfiguration, args: string[] | undefined, env: EnvVar[], launchOptions: LaunchOptions, debuggerExtension: ResourceDebuggerExtension): Promise { +export async function createDebugSessionConfiguration(debugSessionConfig: AspireExtendedDebugConfiguration, launchConfig: ExecutableLaunchConfiguration, args: string[] | undefined, env: EnvVar[], launchOptions: LaunchOptions, debuggerExtension?: ResourceDebuggerExtension): Promise { if (debuggerExtension === null) { extensionLogOutputChannel.warn(`Unknown type: ${launchConfig.type}.`); } - const projectPath = debuggerExtension.getProjectFile(launchConfig); - - const configuration: AspireResourceExtendedDebugConfiguration = { - type: debuggerExtension.debugAdapter || launchConfig.type, - request: 'launch', - name: launchOptions.debug ? debugProject(debuggerExtension.getDisplayName(launchConfig)) : runProject(debuggerExtension.getDisplayName(launchConfig)), - program: projectPath, + const baseConfig = { args: args, - cwd: await isDirectory(projectPath) ? projectPath : path.dirname(projectPath), env: mergeEnvs(process.env, env), - justMyCode: false, - stopAtEntry: false, noDebug: !launchOptions.debug, runId: launchOptions.runId, debugSessionId: launchOptions.debugSessionId, - console: 'internalConsole' }; + let configuration: AspireResourceExtendedDebugConfiguration | undefined; + + if (debuggerExtension) { + // For example, the Python debugger extension has been superceded by apphost-driven debugger properties. + // Thus, we can skip this section if debugger_properties exist and the debugger extension is deprecated. + if (launchConfig.debugger_properties && debuggerExtension.isDeprecated) { + extensionLogOutputChannel.debug(`Skipping debugger configuration for deprecated extension: ${debuggerExtension.extensionId}`); + } + else { + const projectPath = debuggerExtension.getProjectFile(launchConfig); + + configuration = { + type: debuggerExtension.debugAdapter || launchConfig.type, + request: 'launch', + name: launchOptions.debug ? debugProject(debuggerExtension.getDisplayName(launchConfig)) : runProject(debuggerExtension.getDisplayName(launchConfig)), + program: projectPath, + justMyCode: false, + stopAtEntry: false, + cwd: await isDirectory(projectPath) ? projectPath : path.dirname(projectPath), + console: 'internalConsole', + ...baseConfig + }; + } + } + + if (launchConfig.debugger_properties) { + configuration = { + ...baseConfig, + ...launchConfig.debugger_properties + } as AspireResourceExtendedDebugConfiguration; + } + + if (!configuration) { + extensionLogOutputChannel.error(`Failed to create debug configuration for ${JSON.stringify(launchConfig)} - resulting configuration was undefined or empty.`); + throw new Error(invalidLaunchConfiguration(JSON.stringify(configuration))); + } + if (debugSessionConfig.debuggers) { // 1. Check if this is the apphost if (launchOptions.isApphost && debugSessionConfig.debuggers['apphost']) { @@ -55,7 +83,7 @@ export async function createDebugSessionConfiguration(debugSessionConfig: Aspire } - if (debuggerExtension.createDebugSessionConfigurationCallback) { + if (debuggerExtension?.createDebugSessionConfigurationCallback) { await debuggerExtension.createDebugSessionConfigurationCallback(launchConfig, args, env, launchOptions, configuration); } diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 2009733dfcb..5544016c1b0 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -250,6 +250,7 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess extensionId: 'ms-dotnettools.csharp', getDisplayName: (launchConfig: ExecutableLaunchConfiguration) => `C#: ${path.basename((launchConfig as ProjectLaunchConfiguration).project_path)}`, getSupportedFileTypes: () => ['.cs', '.csproj'], + isDeprecated: false, getProjectFile: (launchConfig) => { if (isProjectLaunchConfiguration(launchConfig)) { return launchConfig.project_path; diff --git a/extension/src/debugger/languages/python.ts b/extension/src/debugger/languages/python.ts index 4f5cdeb17e7..cd63cd7d24f 100644 --- a/extension/src/debugger/languages/python.ts +++ b/extension/src/debugger/languages/python.ts @@ -22,6 +22,7 @@ export const pythonDebuggerExtension: ResourceDebuggerExtension = { getDisplayName: (launchConfiguration: ExecutableLaunchConfiguration) => `Python: ${vscode.workspace.asRelativePath(getProjectFile(launchConfiguration))}`, getSupportedFileTypes: () => ['.py'], getProjectFile: (launchConfig) => getProjectFile(launchConfig), + isDeprecated: true, createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { if (!isPythonLaunchConfiguration(launchConfig)) { extensionLogOutputChannel.info(`The resource type was not python for ${JSON.stringify(launchConfig)}`); diff --git a/extension/src/server/rpcClient.ts b/extension/src/server/rpcClient.ts index b6fa61a32ea..759828cd558 100644 --- a/extension/src/server/rpcClient.ts +++ b/extension/src/server/rpcClient.ts @@ -63,7 +63,12 @@ export class RpcClient implements ICliRpcClient { async stopCli() { if (!this._connectionClosed) { - await this._messageConnection.sendRequest('stopCli'); + try { + await this._messageConnection.sendRequest('stopCli'); + } catch (error) { + // Connection may have been closed during shutdown, which is expected + extensionLogOutputChannel.info(`stopCli request failed (likely connection already closed): ${error}`); + } } } diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index edd482ca16e..d49305fabd6 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -142,6 +142,9 @@ export class AspireTerminalProvider implements vscode.Disposable { const env: any = { ...process.env, + // IDE identification for Aspire debug features + ASPIRE_IDE: 'vscode', + // Extension connection information ASPIRE_EXTENSION_ENDPOINT: this.rpcServerConnectionInfo.address, ASPIRE_EXTENSION_TOKEN: this.rpcServerConnectionInfo.token, @@ -164,6 +167,7 @@ export class AspireTerminalProvider implements vscode.Disposable { env.ASPIRE_EXTENSION_DEBUG_RUN_MODE = noDebug === false ? "Debug" : "NoDebug"; env.DEBUG_SESSION_INFO = JSON.stringify(getRunSessionInfo()); env.ASPIRE_EXTENSION_CAPABILITIES = getSupportedCapabilities().join(','); + env.ASPIRE_EXTENSION_WORKSPACE_ROOT = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; // if DCP debug logging is enabled, set DCP-specific logging environment variables const dcpDebugLoggingEnabled = vscode.workspace.getConfiguration('aspire').get('enableAspireDcpDebugLogging', false); diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj index 8af3a5ea549..00d0d70c95c 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj @@ -7,6 +7,8 @@ net8.0 enable enable + + $(NoWarn);ASPIREEXTENSION001 diff --git a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs index 784b93baeab..a0f75deaad4 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs @@ -5,20 +5,12 @@ // Add services to the container. // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); builder.Services.AddCors(); var app = builder.Build(); app.MapDefaultEndpoints(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - app.UseHttpsRedirection(); app.UseCors(static builder => builder.AllowAnyMethod() @@ -42,8 +34,7 @@ .ToArray(); return forecast; }) -.WithName("GetWeatherForecast") -.WithOpenApi(); +.WithName("GetWeatherForecast"); app.UseFileServer(); diff --git a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/Properties/launchSettings.json b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/Properties/launchSettings.json index 4c791b4f6cc..ee034e9ee04 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/Properties/launchSettings.json +++ b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/Properties/launchSettings.json @@ -13,7 +13,6 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", "applicationUrl": "http://localhost:5084", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -23,7 +22,6 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", "applicationUrl": "https://localhost:7167;http://localhost:5084", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/playground/AspireWithJavaScript/AspireJavaScript.NodeApp/app.js b/playground/AspireWithJavaScript/AspireJavaScript.NodeApp/app.js index 9c39903c951..7e0e086f97f 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.NodeApp/app.js +++ b/playground/AspireWithJavaScript/AspireJavaScript.NodeApp/app.js @@ -69,7 +69,7 @@ app.use((err, req, res, next) => { }); // General 404 handler (for non-API routes) -app.use('*', (req, res) => { +app.use((req, res) => { res.status(404).json({ message: 'Route not found', path: req.originalUrl diff --git a/playground/AspireWithJavaScript/AspireJavaScript.React/webpack.config.js b/playground/AspireWithJavaScript/AspireJavaScript.React/webpack.config.js index 016c52e1674..770f78907a3 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.React/webpack.config.js +++ b/playground/AspireWithJavaScript/AspireJavaScript.React/webpack.config.js @@ -3,6 +3,7 @@ const HTMLWebpackPlugin = require("html-webpack-plugin"); module.exports = (env) => { return { entry: "./src/index.js", + devtool: "source-map", devServer: { port: env.PORT || 4001, allowedHosts: "all", diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs index a733ebe748f..702f2dcb24c 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs @@ -1,3 +1,5 @@ +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + var builder = DistributedApplication.CreateBuilder(args); builder.AddAzureContainerAppEnvironment("env"); diff --git a/playground/Stress/Stress.AppHost/Program.cs b/playground/Stress/Stress.AppHost/Program.cs index 001b0b673eb..e07d1ddab88 100644 --- a/playground/Stress/Stress.AppHost/Program.cs +++ b/playground/Stress/Stress.AppHost/Program.cs @@ -1,5 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -161,6 +162,8 @@ .WithEnvironment("ENV_TO_OVERRIDE", "this value came from the apphost") .WithArgs("arg_from_apphost"); +builder.AddProject("empty-profile-3-fallback-to-process"); + builder.Build().Run(); static async Task ExecuteCommandForAllResourcesAsync(IServiceProvider serviceProvider, string commandName, CancellationToken cancellationToken) diff --git a/playground/python/Python.AppHost/Program.cs b/playground/python/Python.AppHost/Program.cs index 3f0b16ee901..0d5a22d3c28 100644 --- a/playground/python/Python.AppHost/Program.cs +++ b/playground/python/Python.AppHost/Program.cs @@ -13,7 +13,6 @@ // Run the same app on another port using uvicorn directly builder.AddPythonExecutable("fastapi-uvicorn-app", "../module_only", "uvicorn") - .WithDebugging() .WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8001") .WithHttpEndpoint(targetPort: 8001); diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index f2e71ee748c..ca34a9c397d 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -373,6 +373,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell InteractionService.DisplayMessage(KnownEmojis.PageFacingUp, string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); return ExitCodeConstants.FailedToDotnetRunAppHost; } + catch (Exception ex) when (ex is ObjectDisposedException || (ex is OperationCanceledException oce && oce.InnerException is ConnectionLostException)) + { + // This occurs when the extension RPC connection is closed during shutdown/restart + // Don't log as unexpected error since this is expected during restart + return ExitCodeConstants.FailedToDotnetRunAppHost; + } catch (Exception ex) { var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message); diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index f73d543b21f..83f3c5e00e2 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using Spectre.Console; using Spectre.Console.Rendering; +using StreamJsonRpc; namespace Aspire.Cli.Interaction; @@ -55,9 +56,22 @@ public ExtensionInteractionService(ConsoleInteractionService consoleInteractionS var taskFunction = await _extensionTaskChannel.Reader.ReadAsync().ConfigureAwait(false); await taskFunction.Invoke(); } + catch (Exception ex) when (IsExtensionConnectionLostException(ex)) + { + // Connection was lost - only log to console, don't try to send over the closed connection + _consoleInteractionService.DisplaySubtleMessage(ex.Message); + } catch (Exception ex) when (ex is not ExtensionOperationCanceledException) { - await Backchannel.DisplayErrorAsync(ex.Message.RemoveSpectreFormatting(), _cancellationToken); + // Try to display error to extension, but if that fails due to connection issues, just log to console + try + { + await Backchannel.DisplayErrorAsync(ex.Message.RemoveSpectreFormatting(), _cancellationToken); + } + catch (Exception innerEx) when (IsExtensionConnectionLostException(innerEx)) + { + // Swallow connection lost exceptions when trying to report the error + } _consoleInteractionService.DisplayError(ex.Message); } } @@ -438,4 +452,15 @@ public void WriteDebugSessionMessage(string message, bool stdout, string? textSt var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.WriteDebugSessionMessageAsync(message.RemoveSpectreFormatting(), stdout, textStyle, _cancellationToken)); Debug.Assert(result); } + + /// + /// Determines if the exception is related to the extension connection being lost. + /// This is used to gracefully handle cases where the debug session is being stopped/restarted. + /// + private static bool IsExtensionConnectionLostException(Exception ex) + { + return ex is ConnectionLostException + || ex is ObjectDisposedException + || (ex is OperationCanceledException && ex.InnerException is ConnectionLostException); + } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index a812cc07b05..c3855218894 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -34,6 +34,7 @@ using Aspire.Cli.Scaffolding; using Aspire.Cli.Telemetry; using Aspire.Cli.Templating; +using StreamJsonRpc; using Aspire.Cli.Utils; using Aspire.Cli.Utils.EnvironmentChecker; using Aspire.Hosting; @@ -638,17 +639,28 @@ public static async Task Main(string[] args) // Catch block is used instead of System.Commandline's default handler behavior. // Allows logging of exceptions to telemetry. - // Don't log or display cancellation exceptions. + // Don't log or display cancellation exceptions or connection lost exceptions (which occur during debug session restart). // Check both Ctrl+C cancellation (cts.IsCancellationRequested) and // extension prompt cancellation (ExtensionOperationCanceledException). - if (!(ex is OperationCanceledException && cts.IsCancellationRequested) && ex is not ExtensionOperationCanceledException) + var isConnectionLost = ex is ConnectionLostException + || ex is ObjectDisposedException + || (ex is OperationCanceledException && ex.InnerException is ConnectionLostException); + + if (!(ex is OperationCanceledException && cts.IsCancellationRequested) && !isConnectionLost && ex is not ExtensionOperationCanceledException) { logger.LogError(ex, "An unexpected error occurred."); telemetry.RecordError("An unexpected error occurred.", ex); - var interactionService = app.Services.GetRequiredService(); - interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message)); + try + { + var interactionService = app.Services.GetRequiredService(); + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message)); + } + catch (Exception displayEx) when (displayEx is ConnectionLostException || displayEx is ObjectDisposedException) + { + // Swallow exceptions when trying to display an error during connection shutdown + } } // Log exit code for debugging diff --git a/src/Aspire.Cli/Utils/ExtensionHelper.cs b/src/Aspire.Cli/Utils/ExtensionHelper.cs index 0ac0170359f..c1c223cebb0 100644 --- a/src/Aspire.Cli/Utils/ExtensionHelper.cs +++ b/src/Aspire.Cli/Utils/ExtensionHelper.cs @@ -34,5 +34,6 @@ internal static class KnownCapabilities public const string BuildDotnetUsingCli = "build-dotnet-using-cli"; public const string Baseline = "baseline.v1"; public const string SecretPrompts = "secret-prompts.v1"; + public const string Browser = "browser"; public const string FilePickers = "file-pickers.v1"; } diff --git a/src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj b/src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj index d49d7d13609..7e8efaa5e3b 100644 --- a/src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj +++ b/src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj @@ -3,16 +3,16 @@ $(DefaultTargetFramework) true - aspire integration hosting Node Nodejs javascript framework runtime - JavaScript support for Aspire. + aspire integration hosting Node Nodejs javascript typescript framework runtime + JavaScript and TypeScript support for Aspire. - + - + diff --git a/src/Aspire.Hosting.JavaScript/BrowserLaunchConfiguration.cs b/src/Aspire.Hosting.JavaScript/BrowserLaunchConfiguration.cs new file mode 100644 index 00000000000..0d4c5e01294 --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/BrowserLaunchConfiguration.cs @@ -0,0 +1,245 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.JavaScript; + +/// +/// A generic resource that represents a browser debugger configuration for JavaScript applications. +/// +/// The type of debugger properties used by this browser debugger resource. +/// +/// This resource is created as a child of a JavaScript application resource when browser debugging is enabled. +/// It launches a controlled browser instance that can be debugged using the IDE's browser debugging extension. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal class BrowserDebuggerResource : ExecutableResource + where T : DebugAdapterProperties +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The type of browser to debug (e.g., "msedge", "chrome"). + /// The working directory for the debugger. + /// The debugger properties for the browser. + public BrowserDebuggerResource( + string name, + string browser, + string workingDirectory, + T debuggerProperties) : base(name, browser, workingDirectory) + { + DebuggerProperties = debuggerProperties; + } + + /// + /// Gets the debugger properties for the browser. + /// + public T DebuggerProperties { get; init; } +} + +/// +/// A VS Code-specific resource that represents a browser debugger configuration for JavaScript applications. +/// +/// +/// This resource is created as a child of a JavaScript application resource when browser debugging is enabled. +/// It launches a controlled browser instance that can be debugged using VS Code's js-debug extension. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal class VSCodeBrowserDebuggerResource : BrowserDebuggerResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The type of browser to debug (e.g., "msedge", "chrome"). + /// The web root directory for the application. + /// The working directory for the debugger. + /// The URL to launch in the browser. + /// An optional action to configure additional debugger properties. + public VSCodeBrowserDebuggerResource( + string name, + string browser, + string webRoot, + string workingDirectory, + string url, + Action? configure) : base(name, browser, workingDirectory, CreateDebuggerProperties(name, browser, webRoot, workingDirectory, url, configure)) + { + } + + private static VSCodeBrowserDebuggerProperties CreateDebuggerProperties( + string name, + string browser, + string webRoot, + string workingDirectory, + string url, + Action? configure) + { + var props = new VSCodeBrowserDebuggerProperties + { + Type = browser, + Name = $"{name} Debugger", + WebRoot = webRoot, + Url = url, + WorkingDirectory = workingDirectory, + SourceMaps = true, + // Use a temporary unique user data directory for each browser instance so that + // multiple browser debuggers can run concurrently without conflicting. + // The boolean true is a js-debug convention that creates an auto-managed temp directory. + UserDataDir = true, + // Allow source maps from anywhere, including webpack dev server + // js-debug has built-in smart resolution for common bundler patterns + ResolveSourceMapLocations = ["**", "!**/node_modules/**"], + }; + + configure?.Invoke(props); + return props; + } +} + +/// +/// Models a runnable debug configuration for browser-based JavaScript debugging. +/// +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +internal sealed class BrowserLaunchConfiguration() : ExecutableLaunchConfigurationWithDebuggerProperties("browser") +#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +{ +} + +/// +/// Models VS Code-specific debugger properties for browser-based JavaScript debugging using the js-debug adapter. +/// +/// +/// These properties map to the VS Code browser debugger configuration options. +/// See https://code.visualstudio.com/docs/nodejs/browser-debugging for more information. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal class VSCodeBrowserDebuggerProperties : VSCodeDebuggerPropertiesBase +{ + /// + /// Identifies the type of debugger to use. Defaults to "msedge" for Edge browser debugging. + /// Other options include "chrome" for Chrome browser debugging. + /// + [JsonPropertyName("type")] + public override string Type { get; set; } = "msedge"; + + /// + /// Provides the name for the debug configuration that appears in the VS Code dropdown list. + /// + [JsonPropertyName("name")] + public override required string Name { get; set; } + + /// + /// Specifies the current working directory for the debugger. + /// + [JsonPropertyName("cwd")] + public override required string WorkingDirectory { get; init; } + + /// + /// The URL to launch in the browser for debugging. + /// + [JsonPropertyName("url")] + public required string Url { get; set; } + + /// + /// The web root directory for the application. Used for resolving source files. + /// + [JsonPropertyName("webRoot")] + public string? WebRoot { get; set; } + + /// + /// Use JavaScript source maps (if they exist). Defaults to true. + /// + [JsonPropertyName("sourceMaps")] + public bool? SourceMaps { get; set; } + + /// + /// A list of glob patterns for locations where source maps should be resolved. + /// + [JsonPropertyName("resolveSourceMapLocations")] + public string[]? ResolveSourceMapLocations { get; set; } + + /// + /// An array of glob patterns for locating generated JavaScript files from source maps. + /// + [JsonPropertyName("outFiles")] + public string[]? OutFiles { get; set; } + + /// + /// A mapping of source file paths to generated file paths for use in source mapping. + /// + [JsonPropertyName("sourceMapPathOverrides")] + public Dictionary? SourceMapPathOverrides { get; set; } + + /// + /// An array of glob patterns for files to skip when debugging. + /// + [JsonPropertyName("skipFiles")] + public string[]? SkipFiles { get; set; } + + /// + /// Automatically step through generated code that cannot be mapped back to the original source. + /// + [JsonPropertyName("smartStep")] + public bool? SmartStep { get; set; } + + /// + /// Enables logging of the Debug Adapter Protocol messages between VS Code and the debug adapter. + /// Can be true, false, or "verbose" for detailed logging. + /// + [JsonPropertyName("trace")] + public object? Trace { get; set; } + + /// + /// The port to use for the browser's remote debugging protocol. + /// + [JsonPropertyName("port")] + public int? Port { get; set; } + + /// + /// Path to the browser executable to use. If not specified, the debugger will try to find one. + /// + [JsonPropertyName("runtimeExecutable")] + public string? RuntimeExecutable { get; set; } + + /// + /// Optional arguments passed to the browser executable. + /// + [JsonPropertyName("runtimeArgs")] + public string[]? RuntimeArgs { get; set; } + + /// + /// The user data directory to use for the browser instance. + /// Set to (boolean) to auto-create a temporary unique directory, + /// or a string path to specify a custom directory. + /// + [JsonPropertyName("userDataDir")] + public object? UserDataDir { get; set; } + + /// + /// Path to a file containing environment variables for the browser. + /// + [JsonPropertyName("envFile")] + public string? EnvFile { get; set; } + + /// + /// The timeout in milliseconds to wait for the browser to attach. + /// + [JsonPropertyName("timeout")] + public int? Timeout { get; set; } + + /// + /// The file to open in the browser. Alternative to URL. + /// + [JsonPropertyName("file")] + public string? File { get; set; } + + /// + /// Path to the server root folder for path mapping. + /// + [JsonPropertyName("pathMapping")] + public Dictionary? PathMapping { get; set; } +} diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 427856c7f10..f6971d10a49 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -4,7 +4,9 @@ #pragma warning disable ASPIREDOCKERFILEBUILDER001 #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIRECERTIFICATES001 +#pragma warning disable ASPIREEXTENSION001 +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json; using Aspire.Hosting.ApplicationModel; @@ -26,6 +28,9 @@ public static class JavaScriptHostingExtensions { private const string DefaultNodeVersion = "22"; + // This must match the value in Aspire.Cli KnownCapabilities.Browser + private const string BrowserCapability = "browser"; + // This is the order of config files that Vite will look for by default // See https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L97 private static readonly string[] s_defaultConfigFiles = ["vite.config.js", "vite.config.mjs", "vite.config.ts", "vite.config.cjs", "vite.config.mts", "vite.config.cts"]; @@ -273,7 +278,7 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl }); } - return resourceBuilder; + return resourceBuilder.WithVSCodeDebugging(scriptPath); } private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource => @@ -458,7 +463,8 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder WithRunScript(this IResourc return resource.WithAnnotation(new JavaScriptRunScriptAnnotation(scriptName, args)); } + /// + /// Configures debugging support for a Node.js/TypeScript resource. + /// + /// The type of the resource. + /// The resource builder. + /// The path to the script to debug. + /// A reference to the for method chaining. + /// + /// + /// This method enables debugging for Node.js/TypeScript applications. + /// The debug configuration includes the Node.js runtime path, script path, and appropriate launch settings. + /// + /// + /// This method is called automatically by . It only needs to be called + /// explicitly when creating custom Node.js resources or when you want to override the script path. + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDebugging(this IResourceBuilder builder, string scriptPath) + where T : NodeAppResource + { + return builder.WithVSCodeDebugging(scriptPath); + } + + /// + /// Configures debugging support for a JavaScript resource that uses a package manager script. + /// + /// The type of the resource. + /// The resource builder. + /// A reference to the for method chaining. + /// + /// + /// This method enables debugging for JavaScript applications that run via package manager scripts + /// (e.g., npm run dev). + /// The debug configuration uses the package manager as the runtime executable. + /// + /// + /// This method is called automatically by and . + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDebugging(this IResourceBuilder builder) + where T : JavaScriptAppResource + { + return builder.WithVSCodeDebugging(); + } + + /// + /// Configures debugging support for a Node.js/TypeScript resource. + /// + /// The type of the resource. + /// The resource builder. + /// The path to the script to debug. + /// A reference to the for method chaining. + /// + /// + /// This method enables debugging for Node.js/TypeScript applications when running in the VS Code extension. + /// The debug configuration includes the Node.js runtime path, script path, and appropriate launch settings. + /// + /// + /// This method is called automatically by . It only needs to be called + /// explicitly when creating custom Node.js resources or when you want to override the script path. + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder, string scriptPath) + where T : NodeAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(scriptPath); + + return builder.WithDebugSupport( + options => + { + var modeText = options.Mode == "Debug" ? "Debug" : "Run"; + var workspaceRoot = builder.ApplicationBuilder.Configuration[KnownConfigNames.ExtensionWorkspaceRoot]; + var displayPath = workspaceRoot is not null + ? Path.GetRelativePath(workspaceRoot, builder.Resource.WorkingDirectory) + : builder.Resource.WorkingDirectory; + + // Check if a run script annotation is present - if so, use package manager instead of direct node + var hasRunScript = builder.Resource.TryGetLastAnnotation(out var runScriptAnnotation); + var hasPackageManager = builder.Resource.TryGetLastAnnotation(out var pmAnnotation); + + VSCodeNodeDebuggerProperties debuggerProperties; + string runtimeExecutable; + + if (hasRunScript && hasPackageManager) + { + runtimeExecutable = pmAnnotation!.ExecutableName; + + debuggerProperties = new VSCodeNodeDebuggerProperties + { + Name = $"{modeText} {runtimeExecutable}: {displayPath}", + WorkingDirectory = builder.Resource.WorkingDirectory, + RuntimeExecutable = runtimeExecutable, + RuntimeArgs = [], + ResolveSourceMapLocations = [$"{builder.Resource.WorkingDirectory}/**", "!**/node_modules/**"] + }; + } + else + { + // Direct node execution mode + runtimeExecutable = "node"; + debuggerProperties = new VSCodeNodeDebuggerProperties + { + Name = $"{modeText} Node.js: {displayPath}", + WorkingDirectory = builder.Resource.WorkingDirectory, + Program = Path.Combine(builder.Resource.WorkingDirectory, scriptPath), + RuntimeExecutable = runtimeExecutable, + ResolveSourceMapLocations = [$"{builder.Resource.WorkingDirectory}/**", "!**/node_modules/**"] + }; + } + + if (builder.Resource.TryGetAnnotationsOfType(out var annotations)) + { + foreach (var annotation in annotations) + { + // Filter by IDE type if specified, and by debugger properties type + if (annotation.IdeType is null || AspireIde.IsCurrentIde(annotation.IdeType)) + { + annotation.ConfigureDebuggerProperties(debuggerProperties); + } + } + } + + return new NodeLaunchConfiguration + { + ScriptPath = scriptPath, + Mode = options.Mode, + RuntimeExecutable = runtimeExecutable, + DebuggerProperties = debuggerProperties + }; + }, + "node"); + } + + /// + /// Configures debugging support for a JavaScript resource that uses a package manager script. + /// + /// The type of the resource. + /// The resource builder. + /// A reference to the for method chaining. + /// + /// + /// This method enables debugging for JavaScript applications that run via package manager scripts + /// (e.g., npm run dev) when running in the VS Code extension. + /// The debug configuration uses the package manager as the runtime executable. + /// + /// + /// This method is called automatically by and . + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder) + where T : JavaScriptAppResource + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithDebugSupport( + options => + { + var modeText = options.Mode == "Debug" ? "Debug" : "Run"; + var workspaceRoot = builder.ApplicationBuilder.Configuration[KnownConfigNames.ExtensionWorkspaceRoot]; + var displayPath = workspaceRoot is not null + ? Path.GetRelativePath(workspaceRoot, builder.Resource.WorkingDirectory) + : builder.Resource.WorkingDirectory; + + // Get package manager info for display name and runtime executable + var packageManager = "npm"; + + if (builder.Resource.TryGetLastAnnotation(out var pmAnnotation)) + { + packageManager = pmAnnotation.ExecutableName; + } + + var debuggerProperties = new VSCodeNodeDebuggerProperties + { + Name = $"{modeText} {packageManager}: {displayPath}", + WorkingDirectory = builder.Resource.WorkingDirectory, + RuntimeExecutable = packageManager, + RuntimeArgs = [], + ResolveSourceMapLocations = [$"{builder.Resource.WorkingDirectory}/**", "!**/node_modules/**"] + }; + + if (builder.Resource.TryGetAnnotationsOfType(out var annotations)) + { + foreach (var annotation in annotations) + { + // Filter by IDE type if specified, and by debugger properties type + if (annotation.IdeType is null || AspireIde.IsCurrentIde(annotation.IdeType)) + { + annotation.ConfigureDebuggerProperties(debuggerProperties); + } + } + } + + return new NodeLaunchConfiguration + { + ScriptPath = string.Empty, + Mode = options.Mode, + RuntimeExecutable = packageManager, + DebuggerProperties = debuggerProperties + }; + }, + "node"); + } + + /// + /// Configures VS Code-specific debugger properties for a Node.js/TypeScript resource. + /// + /// The type of the resource. + /// The resource builder. + /// A callback action to configure the debugger properties. + /// A reference to the for method chaining. + /// + /// + /// This method allows customization of the VS Code debugger configuration that will be used when debugging the resource. + /// The callback receives a object that is pre-populated + /// with default values based on the resource's configuration. You can modify any properties + /// to customize the debugging experience. + /// + /// + /// Debugging is automatically enabled when using , , and . + /// This method can be used to customize the debugger properties. + /// + /// + /// + /// Configure Node.js debugger to stop on entry: + /// + /// var api = builder.AddNodeApp("api", "../api", "server.js") + /// .WithVSCodeNodeDebuggerProperties(props => + /// { + /// props.StopOnEntry = true; + /// }); + /// + /// + /// + /// Enable automatic child process debugging: + /// + /// var worker = builder.AddNodeApp("worker", "../worker", "index.js") + /// .WithVSCodeNodeDebuggerProperties(props => + /// { + /// props.AutoAttachChildProcesses = true; + /// }); + /// + /// + /// + /// Configure source map locations for TypeScript projects: + /// + /// var app = builder.AddNodeApp("app", "../app", "dist/index.js") + /// .WithVSCodeNodeDebuggerProperties(props => + /// { + /// props.OutFiles = ["${workspaceFolder}/dist/**/*.js"]; + /// }); + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeNodeDebuggerProperties( + this IResourceBuilder builder, + Action configureDebuggerProperties) + where T : JavaScriptAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureDebuggerProperties); + + builder.WithAnnotation(new ExecutableDebuggerPropertiesAnnotation(configureDebuggerProperties, AspireIde.VSCode)); + return builder; + } + + /// + /// Configures browser debugging support for a JavaScript resource by creating a child browser debugger resource. + /// + /// The type of the JavaScript resource. + /// The resource builder. + /// The browser to use for debugging (e.g., "msedge", "chrome"). Defaults to "msedge". + /// A reference to the for method chaining. + /// + /// + /// This method creates a child that launches a controlled browser instance + /// for debugging JavaScript code running in the browser. The browser is managed by VS Code's js-debug extension. + /// + /// + /// The resource must have an HTTP or HTTPS endpoint configured. If no endpoint is found, an + /// is thrown. + /// + /// + /// + /// Thrown when the resource does not have an HTTP or HTTPS endpoint configured. + /// + /// + /// Add browser debugging to a Vite app: + /// + /// var frontend = builder.AddViteApp("frontend", "../frontend") + /// .WithBrowserDebugger(); + /// + /// + /// + /// Use Chrome instead of Edge: + /// + /// var frontend = builder.AddViteApp("frontend", "../frontend") + /// .WithBrowserDebugger(browser: "chrome"); + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithBrowserDebugger( + this IResourceBuilder builder, + string browser = "msedge") + where T : JavaScriptAppResource + { + return builder.WithBrowserDebugger(browser, configureDebuggerProperties: null); + } + + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithBrowserDebugger( + this IResourceBuilder builder, + string browser, + Action? configureDebuggerProperties) + where T : JavaScriptAppResource + { + ArgumentNullException.ThrowIfNull(builder); + + // Validate that the extension supports browser debugging if we're running in an extension context + ValidateBrowserCapability(builder); + + var parentResource = builder.Resource; + var debuggerResourceName = $"{parentResource.Name}-browser"; + + // Create a placeholder debugger resource - the URL will be resolved when the callback is invoked + var debuggerResource = new VSCodeBrowserDebuggerResource( + debuggerResourceName, + browser, + parentResource.WorkingDirectory, + parentResource.WorkingDirectory, + "placeholder", // URL will be resolved in the callback after endpoints are allocated + configureDebuggerProperties); + + // Find the parent's HTTP/HTTPS endpoint + EndpointAnnotation? endpointAnnotation = null; + if (parentResource.TryGetAnnotationsOfType(out var endpoints)) + { + endpointAnnotation = endpoints.FirstOrDefault(e => e.UriScheme == "https") + ?? endpoints.FirstOrDefault(e => e.UriScheme == "http"); + } + + if (endpointAnnotation is null) + { + throw new InvalidOperationException($"Resource '{parentResource.Name}' does not have an HTTP or HTTPS endpoint. Browser debugging requires an endpoint to navigate to."); + } + + var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name); + + builder.ApplicationBuilder.AddResource(debuggerResource) + .WithParentRelationship(parentResource) + .WaitFor(builder) + .ExcludeFromManifest() + .WithDebugSupport( + options => + { + // The callback is invoked after endpoints are allocated, so we can access the URL directly + debuggerResource.DebuggerProperties.Url = endpointReference.Url; + + return new BrowserLaunchConfiguration + { + Mode = options.Mode, + DebuggerProperties = debuggerResource.DebuggerProperties + }; + }, + "browser"); + + return builder; + } + private static void AddInstaller(IResourceBuilder resource, bool install) where TResource : JavaScriptAppResource { // Only install packages if in run mode @@ -1124,4 +1503,26 @@ private static bool TryParseNodeVersion(string versionString, out string majorVe return false; } + + /// + /// Validates that the extension supports browser debugging when running in an extension context. + /// + /// The type of the JavaScript resource. + /// The resource builder. + /// + /// Thrown when the extension does not support browser debugging and needs to be updated. + /// + private static void ValidateBrowserCapability(IResourceBuilder builder) where T : IResource + { + var configuration = builder.ApplicationBuilder.Configuration; + var supportedLaunchConfigurations = configuration.GetSupportedLaunchConfigurations(); + + // If we're in a debug session context (DEBUG_SESSION_INFO is set) but browser is not in the supported capabilities, + // throw an error asking the user to update the extension + if (supportedLaunchConfigurations is not null && !supportedLaunchConfigurations.Contains(BrowserCapability)) + { + throw new InvalidOperationException( + "This version of the Aspire extension does not support browser debugging. Please update the Aspire extension to use browser debugging support with WithBrowserDebugger()."); + } + } } diff --git a/src/Aspire.Hosting.JavaScript/NodeLaunchConfiguration.cs b/src/Aspire.Hosting.JavaScript/NodeLaunchConfiguration.cs new file mode 100644 index 00000000000..bf1e204a4f0 --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/NodeLaunchConfiguration.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Aspire.Hosting.JavaScript; + +/// +/// Models a runnable debug configuration for a Node.js/TypeScript application. +/// +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +internal sealed class NodeLaunchConfiguration() : ExecutableLaunchConfigurationWithDebuggerProperties("node") +#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +{ + /// + /// Provides the path to the Node.js script to run. + /// + [JsonPropertyName("script_path")] + public string ScriptPath { get; set; } = string.Empty; + + /// + /// Provides the path to the Node.js runtime executable. + /// + [JsonPropertyName("runtime_executable")] + public string RuntimeExecutable { get; set; } = string.Empty; +} + +/// +/// Models VS Code-specific debugger properties for a Node.js/TypeScript application made available by the VS Code js-debug adapter. +/// +/// +/// These properties map to the VS Code Node.js debugger configuration options. +/// See https://code.visualstudio.com/docs/nodejs/nodejs-debugging for more information. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal class VSCodeNodeDebuggerProperties : VSCodeDebuggerPropertiesBase +{ + /// + /// Identifies the type of debugger to use. Defaults to "node" which uses the built-in js-debug. + /// + [JsonPropertyName("type")] + public override string Type { get; set; } = "node"; + + /// + /// Provides the name for the debug configuration that appears in the VS Code dropdown list. + /// + [JsonPropertyName("name")] + public override required string Name { get; set; } + + /// + /// Specifies the current working directory for the debugger. + /// + [JsonPropertyName("cwd")] + public override required string WorkingDirectory { get; init; } + + /// + /// Absolute path to the program to debug. This is the entry point script. + /// + [JsonPropertyName("program")] + public string? Program { get; set; } + + /// + /// Runtime to use. Either an absolute path or the name of a runtime available on the PATH. + /// Defaults to "node". + /// + [JsonPropertyName("runtimeExecutable")] + public string? RuntimeExecutable { get; set; } + + /// + /// Optional arguments passed to the runtime executable. + /// + [JsonPropertyName("runtimeArgs")] + public string[]? RuntimeArgs { get; set; } + + /// + /// When set to true, breaks the debugger at the first line of the program. + /// + [JsonPropertyName("stopOnEntry")] + public bool? StopOnEntry { get; set; } + + /// + /// Specifies how program output is displayed. + /// Valid values: "internalConsole", "integratedTerminal", "externalTerminal". + /// + [JsonPropertyName("console")] + public string Console { get; set; } = "internalConsole"; + + /// + /// An array of glob patterns for files to skip when debugging. + /// The pattern <node_internals>/** skips Node.js internal modules. + /// + [JsonPropertyName("skipFiles")] + public string[]? SkipFiles { get; set; } = ["/**"]; + + /// + /// Use JavaScript source maps (if they exist). Defaults to true. + /// + [JsonPropertyName("sourceMaps")] + public bool? SourceMaps { get; set; } = true; + + /// + /// An array of glob patterns for locating generated JavaScript files from source maps. + /// + [JsonPropertyName("outFiles")] + public string[]? OutFiles { get; set; } + + /// + /// Automatically step through generated code that cannot be mapped back to the original source. + /// + [JsonPropertyName("smartStep")] + public bool? SmartStep { get; set; } + + /// + /// Retry connecting to Node.js after this number of milliseconds. Useful for slow-starting programs. + /// + [JsonPropertyName("timeout")] + public int? Timeout { get; set; } + + /// + /// Restart the session when the debugged program exits. + /// + [JsonPropertyName("restart")] + public bool? Restart { get; set; } + + /// + /// Track all subprocesses of the program and debug them too. + /// + [JsonPropertyName("autoAttachChildProcesses")] + public bool? AutoAttachChildProcesses { get; set; } = true; + + /// + /// A list of glob patterns for locations where source maps should be resolved. + /// + [JsonPropertyName("resolveSourceMapLocations")] + public string[]? ResolveSourceMapLocations { get; set; } + + /// + /// Path to an environment variable definitions file (.env file) to load. + /// + [JsonPropertyName("envFile")] + public string? EnvFile { get; set; } + + /// + /// Enables logging of the Debug Adapter Protocol messages between VS Code and the debug adapter. + /// + [JsonPropertyName("trace")] + public bool? Trace { get; set; } + + /// + /// The address of the host to connect to for remote debugging. + /// + [JsonPropertyName("address")] + public string? Address { get; set; } + + /// + /// The port to use for remote debugging. + /// + [JsonPropertyName("port")] + public int? Port { get; set; } + + /// + /// When debugging TypeScript, generate source maps on the fly. + /// This requires ts-node to be installed in the project. + /// + [JsonPropertyName("runtimeSourcemapPausePatterns")] + public string[]? RuntimeSourcemapPausePatterns { get; set; } + + /// + /// Locations where source maps can be found. Useful when source maps are not next to their source files. + /// + [JsonPropertyName("sourceMapPathOverrides")] + public Dictionary? SourceMapPathOverrides { get; set; } +} diff --git a/src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj b/src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj index 25d5b07fa13..0bdfccf71dc 100644 --- a/src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj +++ b/src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj @@ -7,12 +7,6 @@ Python support for Aspire. - - - - - - diff --git a/src/Aspire.Hosting.Python/PythonAppLaunchConfiguration.cs b/src/Aspire.Hosting.Python/PythonAppLaunchConfiguration.cs index 926a5114170..f5ad71a32fb 100644 --- a/src/Aspire.Hosting.Python/PythonAppLaunchConfiguration.cs +++ b/src/Aspire.Hosting.Python/PythonAppLaunchConfiguration.cs @@ -1,19 +1,159 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; -using Aspire.Hosting.Dcp.Model; namespace Aspire.Hosting.Python; -internal sealed class PythonLaunchConfiguration() : ExecutableLaunchConfiguration("python") +/// +/// Models a runnable debug configuration for a python application. +/// +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +internal sealed class PythonLaunchConfiguration() : ExecutableLaunchConfigurationWithDebuggerProperties("python") +#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. { + /// + /// Provides the fully qualified path to the python program's entry module (startup file). + /// + /// is mutually exclusive with the property. One of the two must be provided. + /// + /// [JsonPropertyName("program_path")] public string ProgramPath { get; set; } = string.Empty; + /// + /// Provides the ability to specify the name of a module to be debugged, similarly to the -m argument when run at the command line. + /// + /// is mutually exclusive with the property. One of the two must be provided. + /// + /// [JsonPropertyName("module")] public string Module { get; set; } = string.Empty; + /// + /// Provides the fully qualified path to the python interpreter to be used to launch the application. + /// [JsonPropertyName("interpreter_path")] public string InterpreterPath { get; set; } = string.Empty; } + +/// +/// Models VS Code-specific debugger properties for a python application made available by the debugpy debug adapter. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal class VSCodePythonDebuggerProperties : VSCodeDebuggerPropertiesBase +{ + /// + /// Identifies the type of debugger to use. + /// + [JsonPropertyName("type")] + public override string Type { get; set; } = "debugpy"; + + /// + /// Provides the name for the debug configuration that appears in the VS Code dropdown list. + /// + [JsonPropertyName("name")] + public override required string Name { get; set; } + + /// + /// Provides the fully qualified path to the python interpreter to be used to launch the application. + /// + [JsonPropertyName("python")] + public required string InterpreterPath { get; init; } + + /// + /// Specifies arguments to pass to the Python interpreter. + /// + [JsonPropertyName("pythonArgs")] + public string[]? PythonArgs { get; set; } + + /// + /// When set to true, activates debugging features specific to the Jinja templating framework. + /// + [JsonPropertyName("jinja")] + public bool Jinja { get; set; } = true; + + /// + /// Provides the fully qualified path to the python program's entry module (startup file). + /// + /// is mutually exclusive with the property. One of the two must be provided. + /// + /// + [JsonPropertyName("program")] + public string? ProgramPath { get; init; } + + /// + /// Provides the ability to specify the name of a module to be debugged, similarly to the -m argument when run at the command line. + /// + /// is mutually exclusive with the property. One of the two must be provided. + /// + /// + [JsonPropertyName("module")] + public string? Module { get; init; } + + /// + /// When set to true, breaks the debugger at the first line of the program being debugged. If omitted (the default) or set to false, the debugger runs the program to the first breakpoint. + /// + [JsonPropertyName("stopOnEntry")] + public bool StopOnEntry { get; set; } = false; + + /// + /// When omitted or set to true (the default), restricts debugging to user-written code only. Set to false to also enable debugging of standard library functions. + /// + [JsonPropertyName("justMyCode")] + public bool JustMyCode { get; set; } = false; + + /// + /// Specifies the current working directory for the debugger, which is the base folder for any relative paths used in code. + /// + [JsonPropertyName("cwd")] + public override required string WorkingDirectory { get; init; } + + /// + /// Specifies how program output is displayed. + /// Due to current limitations with the VS Code implementation, only a value of internalConsole is supported. + /// + [JsonPropertyName("console")] + public string Console { get; } = "internalConsole"; + + /// + /// When set to true, activates debugging features specific to the Django web framework. + /// + [JsonPropertyName("django")] + public bool? Django { get; set; } + + /// + /// If set to true, enables debugging of gevent monkey-patched code. + /// + [JsonPropertyName("gevent")] + public bool? Gevent { get; set; } + + /// + /// There is more than one way to configure the Run button, using the purpose option. Setting the option to debug-test, defines that the configuration should be used when debugging tests in VS Code. However, setting the option to debug-in-terminal, defines that the configuration should only be used when accessing the Run Python File button on the top-right of the editor (regardless of whether the Run Python File or Debug Python File options the button provides is used). + /// Note: The purpose option can't be used to start the debugger through F5 or Run > Start Debugging. + /// + /// This property is only applicable to VS Code. + /// + /// + [JsonPropertyName("purpose")] + public string? Purpose { get; set; } + + /// + /// Allows for the automatic reload of the debugger when changes are made to code after the debugger execution has hit a breakpoint + /// + [JsonPropertyName("autoReload")] + public PythonAutoReloadOptions? AutoReload { get; set; } +} + +/// +/// Models options for automatic reloading of the debugger in a python application. +/// +internal sealed class PythonAutoReloadOptions +{ + /// + /// When set to true, enables automatic reloading of the debugger when changes are made to code after the debugger execution has hit a breakpoint. + /// + [JsonPropertyName("enable")] + public bool Enable { get; set; } +} diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 70e2c803cb0..6ff1d4a72cc 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.ApplicationModel.Docker; @@ -65,7 +66,7 @@ public static class PythonAppResourceBuilderExtensions public static IResourceBuilder AddPythonApp( this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath) => AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder) - .WithDebugging(); + .WithVSCodeDebugging(); /// /// Adds a Python module to the application model. @@ -100,7 +101,7 @@ public static IResourceBuilder AddPythonApp( public static IResourceBuilder AddPythonModule( this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string moduleName) => AddPythonAppCore(builder, name, appDirectory, EntrypointType.Module, moduleName, DefaultVirtualEnvFolder) - .WithDebugging(); + .WithVSCodeDebugging(); /// /// Adds a Python executable to the application model. @@ -119,7 +120,7 @@ public static IResourceBuilder AddPythonModule( /// /// /// Unlike scripts and modules, Python executables do not have debugging support enabled by default. - /// Use to explicitly enable debugging support if the executable is a Python-based + /// Use to explicitly enable debugging support if the executable is a Python-based /// tool that can be debugged. /// /// @@ -130,7 +131,7 @@ public static IResourceBuilder AddPythonModule( /// /// builder.AddPythonExecutable("pytest", "../api", "pytest") /// .WithArgs("-q") - /// .WithDebugging(); + /// .WithVSCodeDebugging(); /// /// builder.Build().Run(); /// @@ -175,7 +176,7 @@ public static IResourceBuilder AddPythonApp( ArgumentException.ThrowIfNullOrEmpty(scriptPath); ThrowIfNullOrContainsIsNullOrEmpty(scriptArgs); return AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder) - .WithDebugging() + .WithVSCodeDebugging() .WithArgs(scriptArgs); } @@ -218,7 +219,7 @@ public static IResourceBuilder AddPythonApp( ThrowIfNullOrContainsIsNullOrEmpty(scriptArgs); ArgumentException.ThrowIfNullOrEmpty(scriptPath); return AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, virtualEnvironmentPath) - .WithDebugging() + .WithVSCodeDebugging() .WithArgs(scriptArgs); } @@ -269,7 +270,7 @@ public static IResourceBuilder AddUvicornApp( "uvicorn", DefaultVirtualEnvFolder, (n, e, d) => new UvicornAppResource(n, e, d)) - .WithDebugging() + .WithVSCodeDebugging() .WithHttpEndpoint(env: "PORT") .WithArgs(c => { @@ -907,8 +908,8 @@ public static IResourceBuilder WithVirtualEnvironment( /// the program or module to debug, and appropriate launch settings. /// /// - public static IResourceBuilder WithDebugging( - this IResourceBuilder builder) where T : PythonAppResource + internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder) + where T : PythonAppResource { ArgumentNullException.ThrowIfNull(builder); @@ -939,12 +940,13 @@ public static IResourceBuilder WithDebugging( } builder.WithDebugSupport( - mode => + options => { string interpreterPath; if (!builder.Resource.TryGetLastAnnotation(out var annotation) || annotation.VirtualEnvironment is null) { - interpreterPath = string.Empty; + options.DebugConsoleLogger.LogWarning("No virtual environment configured for resource '{ResourceName}'. Falling back to system 'python'.", builder.Resource.Name); + interpreterPath = "python"; } else { @@ -960,14 +962,45 @@ public static IResourceBuilder WithDebugging( { interpreterPath = Path.Join(venvPath, "bin", "python"); } + + options.DebugConsoleLogger.LogDebug("Using Python interpreter '{InterpreterPath}' for resource '{ResourceName}'", interpreterPath, builder.Resource.Name); + } + + var modeText = options.Mode == "Debug" ? "Debug" : "Run"; + var workspaceRoot = builder.ApplicationBuilder.Configuration[KnownConfigNames.ExtensionWorkspaceRoot]; + var displayProgramPath = workspaceRoot is not null + ? Path.GetRelativePath(workspaceRoot, programPath) + : programPath; + + var debuggerProperties = new VSCodePythonDebuggerProperties + { + InterpreterPath = interpreterPath, + Module = string.IsNullOrEmpty(module) ? null : module, + ProgramPath = programPath, + Jinja = true, // by default, activate Jinja support, + Name = $"{modeText} Python: {displayProgramPath}", + WorkingDirectory = builder.Resource.WorkingDirectory + }; + + if (builder.Resource.TryGetAnnotationsOfType(out var debugAnnotations)) + { + foreach (var debugAnnotation in debugAnnotations) + { + // Filter by IDE type if specified, and by debugger properties type + if (debugAnnotation.IdeType is null || AspireIde.IsCurrentIde(debugAnnotation.IdeType)) + { + debugAnnotation.ConfigureDebuggerProperties(debuggerProperties); + } + } } return new PythonLaunchConfiguration { ProgramPath = programPath, Module = module, - Mode = mode, - InterpreterPath = interpreterPath + Mode = options.Mode, + InterpreterPath = interpreterPath, + DebuggerProperties = debuggerProperties }; }, "python", @@ -1005,6 +1038,28 @@ public static IResourceBuilder WithDebugging( return builder; } + /// + /// Enables debugging support for the Python application. + /// + /// The type of the Python application resource. + /// The resource builder. + /// A reference to the for method chaining. + /// + /// + /// This method adds debugging support for Python applications. + /// The debugging configuration is automatically set up based on the + /// entrypoint type (Script, Module, or Executable). + /// + /// + /// The debug configuration includes the Python interpreter path from the virtual environment, + /// the program or module to debug, and appropriate launch settings. + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDebugging(this IResourceBuilder builder) + where T : PythonAppResource + => builder.WithVSCodeDebugging(); + /// /// Configures the entrypoint for the Python application. /// @@ -1247,6 +1302,63 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo return builder; } + /// + /// Configures VS Code-specific debugger properties for a Python resource. + /// + /// The type of the resource. + /// The resource builder. + /// A callback action to configure the debugger properties. + /// A reference to the for method chaining. + /// + /// + /// This method allows customization of the VS Code debugger configuration that will be used when debugging the resource. + /// The callback receives an object that is pre-populated with default values based on the resource's configuration. + /// You can modify any properties to customize the debugging experience. + /// + /// + /// + /// Configure Python debugger to stop on entry: + /// + /// var api = builder.AddPythonScript("script", "../app", "main.py") + /// .WithVSCodePythonDebuggerProperties(props => + /// { + /// props.StopOnEntry = true; // Stop execution at entrypoint + /// }) + /// + /// + /// + /// Enable automatic reload for faster development: + /// + /// var script = builder.AddPythonScript("worker", "../worker", "worker.py") + /// .WithVSCodePythonDebuggerProperties(props => + /// { + /// props.AutoReload = new PythonAutoReloadOptions { Enable = true }; + /// }) + /// + /// + /// + /// Pass custom arguments to the Python interpreter: + /// + /// var app = builder.AddPythonModule("app", "../app", "myapp") + /// .WithVSCodePythonDebuggerProperties(props => + /// { + /// props.PythonArgs = ["-X", "dev", "-W", "default"]; + /// }) + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodePythonDebuggerProperties( + this IResourceBuilder builder, + Action configureDebuggerProperties) + where T : PythonAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureDebuggerProperties); + + builder.WithAnnotation(new ExecutableDebuggerPropertiesAnnotation(configureDebuggerProperties, AspireIde.VSCode)); + return builder; + } + private static bool IsPythonCommandAvailable(string command) { var pathVariable = Environment.GetEnvironmentVariable("PATH"); diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 6d65b91a9d4..06a9a7ec6e5 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -44,6 +44,7 @@ + @@ -112,6 +113,9 @@ + + + diff --git a/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs b/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs index 4211d04234b..161322d0727 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs @@ -6,7 +6,9 @@ namespace Aspire.Hosting.Backchannel; -internal class BackchannelLoggerProvider : ILoggerProvider +internal interface IBackchannelLoggerProvider : ILoggerProvider; + +internal class BackchannelLoggerProvider : IBackchannelLoggerProvider { private readonly Queue _replayBuffer = new(); private readonly object _lock = new(); diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index f953a02bdcb..82cbb175ae5 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -71,4 +71,11 @@ lib/net8.0/Aspire.Hosting.dll true + + CP0002 + M:Aspire.Hosting.ResourceBuilderExtensions.WithDebugSupport``2(Aspire.Hosting.ApplicationModel.IResourceBuilder{``0},System.Func{System.String,``1},System.String,System.Action{Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext}) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + \ No newline at end of file diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 4edf0ce335d..e56d4fbe07d 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -22,6 +22,7 @@ using System.Threading.Channels; using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Backchannel; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Eventing; @@ -81,6 +82,7 @@ internal sealed partial class DcpExecutor : IDcpExecutor, IConsoleLogsService, I private readonly DcpResourceState _resourceState; private readonly ResourceSnapshotBuilder _snapshotBuilder; private readonly SemaphoreSlim _serverCertificateCacheSemaphore = new(1, 1); + private readonly IBackchannelLoggerProvider _backchannelLoggerProvider; private readonly string _normalizedApplicationName; @@ -112,7 +114,8 @@ public DcpExecutor(ILogger logger, DcpNameGenerator nameGenerator, DcpExecutorEvents executorEvents, Locations locations, - IDeveloperCertificateService developerCertificateService) + IDeveloperCertificateService developerCertificateService, + IBackchannelLoggerProvider backchannelLoggerProvider) { _distributedApplicationLogger = distributedApplicationLogger; _kubernetesService = kubernetesService; @@ -132,6 +135,7 @@ public DcpExecutor(ILogger logger, _normalizedApplicationName = NormalizeApplicationName(hostEnvironment.ApplicationName); _locations = locations; _developerCertificateService = developerCertificateService; + _backchannelLoggerProvider = backchannelLoggerProvider; DeleteResourceRetryPipeline = DcpPipelineBuilder.BuildDeleteRetryPipeline(logger); WatchResourceRetryPipeline = DcpPipelineBuilder.BuildWatchResourcePipeline(logger); @@ -1323,13 +1327,17 @@ private void PreparePlainExecutables() exe.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, exeInstance.Suffix); exe.Annotate(CustomResource.ResourceNameAnnotation, executable.Name); - if (executable.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) + var isProcessExecution = true; + if (executable.SupportsDebugging(_configuration, out _)) { + // Just mark as IDE execution here - the actual callback will be invoked + // in CreateExecutableAsync after endpoints are allocated exe.Spec.ExecutionType = ExecutionType.IDE; - exe.Spec.FallbackExecutionTypes = [ ExecutionType.Process ]; - supportsDebuggingAnnotation.LaunchConfigurationAnnotator(exe, _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug); + exe.Spec.FallbackExecutionTypes = [ExecutionType.Process]; + isProcessExecution = false; } - else + + if (isProcessExecution) { exe.Spec.ExecutionType = ExecutionType.Process; } @@ -1371,24 +1379,19 @@ private void PrepareProjectExecutables() SetInitialResourceState(project, exe); - var projectLaunchConfiguration = new ProjectLaunchConfiguration(); - projectLaunchConfiguration.ProjectPath = projectMetadata.ProjectPath; - var projectArgs = new List(); + var isProcessExecution = true; if (project.SupportsDebugging(_configuration, out _)) { + // Just mark as IDE execution here - the actual callback will be invoked + // in CreateExecutableAsync after endpoints are allocated exe.Spec.ExecutionType = ExecutionType.IDE; - exe.Spec.FallbackExecutionTypes = [ ExecutionType.Process ]; - - projectLaunchConfiguration.DisableLaunchProfile = project.TryGetLastAnnotation(out _); - // Use the effective launch profile which has fallback logic - if (!projectLaunchConfiguration.DisableLaunchProfile && project.GetEffectiveLaunchProfile() is NamedLaunchProfile namedLaunchProfile) - { - projectLaunchConfiguration.LaunchProfile = namedLaunchProfile.Name; - } + exe.Spec.FallbackExecutionTypes = [ExecutionType.Process]; + isProcessExecution = false; } - else + + if (isProcessExecution) { exe.Spec.ExecutionType = ExecutionType.Process; @@ -1429,10 +1432,15 @@ private void PrepareProjectExecutables() // and should be HIGHER priority than the launch profile settings). // This means we need to apply the launch profile settings manually inside CreateExecutableAsync(). projectArgs.Add("--no-launch-profile"); + + var projectLaunchConfiguration = new ProjectLaunchConfiguration + { + ProjectPath = projectMetadata.ProjectPath + }; + + exe.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, projectLaunchConfiguration); } - // We want this annotation even if we are not using IDE execution; see ToSnapshot() for details. - exe.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, projectLaunchConfiguration); exe.SetAnnotationAsObjectList(CustomResource.ResourceProjectArgsAnnotation, projectArgs); var exeAppResource = new RenderedModelResource(project, exe); @@ -1747,6 +1755,53 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou throw new FailedToApplyEnvironmentException(); } + // Invoke the debug configuration callback now that endpoints are allocated. + // This allows launch configurations to access endpoint URLs that were not available during PrepareExecutables(). + if (er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) + { + var launchConfigurationProducerOptions = new LaunchConfigurationProducerOptions + { + DebugConsoleLogger = _backchannelLoggerProvider.CreateLogger(er.ModelResource.Name), + Mode = _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug + }; + + // For project resources, add additional configuration for launch profile settings + if (er.ModelResource is ProjectResource project) + { + launchConfigurationProducerOptions.AdditionalConfiguration = launchConfiguration => + { + if (launchConfiguration is not ProjectLaunchConfiguration projectLaunchConfiguration) + { + throw new InvalidOperationException("Expected a ProjectLaunchConfiguration. The SupportsDebuggingAnnotation launch configuration producer must produce a ProjectLaunchConfiguration for project resources."); + } + + projectLaunchConfiguration.DisableLaunchProfile = project.TryGetLastAnnotation(out _); + + // Use the effective launch profile which has fallback logic + if (!projectLaunchConfiguration.DisableLaunchProfile && project.GetEffectiveLaunchProfile() is NamedLaunchProfile namedLaunchProfile) + { + projectLaunchConfiguration.LaunchProfile = namedLaunchProfile.Name; + } + }; + } + + try + { + // Clear existing launch configurations before re-annotating. + // On restart, AnnotateAsObjectList deserializes the existing list, but DebugAdapterProperties + // (abstract) deserializes as null, causing old configs to lose their debugger_properties. + // Clearing first ensures only the fresh configuration with correct debugger_properties is used. + exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty); + + supportsDebuggingAnnotation.LaunchConfigurationAnnotator(exe, launchConfigurationProducerOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to configure debugging for resource '{ResourceName}'. Falling back to process execution.", er.ModelResource.Name); + spec.ExecutionType = ExecutionType.Process; + } + } + await _kubernetesService.CreateAsync(exe, cancellationToken).ConfigureAwait(false); } finally diff --git a/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs deleted file mode 100644 index 5795877e4b6..00000000000 --- a/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs +++ /dev/null @@ -1,43 +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 System.Text.Json.Serialization; - -namespace Aspire.Hosting.Dcp.Model; - -internal static class ExecutableLaunchMode -{ - public const string Debug = "Debug"; - public const string NoDebug = "NoDebug"; -} - -/// -/// Base properties for all executable launch configurations. -/// -/// Launch configuration type indicator. -internal class ExecutableLaunchConfiguration(string type) -{ - /// - /// The launch configuration type indicator. - /// - [JsonPropertyName("type")] - public string Type { get; set; } = type; - - /// - /// Specifies the launch mode. Currently supported modes are Debug (run the project under the debugger) and NoDebug (run the project without debugging). - /// - [JsonPropertyName("mode")] - public string Mode { get; set; } = System.Diagnostics.Debugger.IsAttached ? ExecutableLaunchMode.Debug : ExecutableLaunchMode.NoDebug; -} - -internal class ProjectLaunchConfiguration() : ExecutableLaunchConfiguration("project") -{ - [JsonPropertyName("launch_profile")] - public string LaunchProfile { get; set; } = string.Empty; - - [JsonPropertyName("disable_launch_profile")] - public bool DisableLaunchProfile { get; set; } = false; - - [JsonPropertyName("project_path")] - public string ProjectPath { get; set; } = string.Empty; -} diff --git a/src/Aspire.Hosting/Dcp/Model/ModelCommon.cs b/src/Aspire.Hosting/Dcp/Model/ModelCommon.cs index 1dbc18004be..65df5ca11f5 100644 --- a/src/Aspire.Hosting/Dcp/Model/ModelCommon.cs +++ b/src/Aspire.Hosting/Dcp/Model/ModelCommon.cs @@ -10,6 +10,13 @@ namespace Aspire.Hosting.Dcp.Model; using k8s; using k8s.Models; +/// +/// Attribute to mark types that should have null values omitted during JSON serialization. +/// This is used to avoid sending nulls to debuggers that can't deserialize them. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = true)] +internal sealed class IgnoreNullsOnSerializationAttribute : Attribute { } + internal interface IAnnotationHolder { void Annotate(string annotationName, string value); @@ -132,7 +139,12 @@ internal static void AnnotateAsObjectList(IDictionary an values = [value]; } - var newAnnotationVal = JsonSerializer.Serialize>(values); + // Use ignore-null options for types marked with IgnoreNullsOnSerializationAttribute + // to avoid sending nulls to debuggers that can't deserialize them + var options = typeof(TValue).GetCustomAttributes(typeof(IgnoreNullsOnSerializationAttribute), inherit: true).Length > 0 + ? new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull } + : null; + var newAnnotationVal = JsonSerializer.Serialize>(values, options!); annotations[annotationName] = newAnnotationVal; } } diff --git a/src/Aspire.Hosting/Dcp/Model/RunSessionInfo.cs b/src/Aspire.Hosting/Dcp/Model/RunSessionInfo.cs deleted file mode 100644 index 38224ea67d8..00000000000 --- a/src/Aspire.Hosting/Dcp/Model/RunSessionInfo.cs +++ /dev/null @@ -1,15 +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 System.Text.Json.Serialization; - -namespace Aspire.Hosting.Dcp.Model; - -internal sealed class RunSessionInfo -{ - [JsonPropertyName("protocols_supported")] - public required string[] ProtocolsSupported { get; set; } - - [JsonPropertyName("supported_launch_configurations")] - public string[]? SupportedLaunchConfigurations { get; set; } -} diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index d472d4e870c..846c452e324 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -205,6 +205,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); + _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); _innerBuilder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); _innerBuilder.Logging.AddFilter("Microsoft.AspNetCore.Server.Kestrel", LogLevel.Error); _innerBuilder.Logging.AddFilter("Aspire.Hosting.Dashboard", LogLevel.Error); diff --git a/src/Aspire.Hosting/ExecutableDebuggerPropertiesAnnotation.cs b/src/Aspire.Hosting/ExecutableDebuggerPropertiesAnnotation.cs new file mode 100644 index 00000000000..2f4208c513f --- /dev/null +++ b/src/Aspire.Hosting/ExecutableDebuggerPropertiesAnnotation.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Non-generic interface for debugger properties annotations, enabling IDE-agnostic annotation lookup. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal interface IDebuggerPropertiesAnnotation : IResourceAnnotation +{ + /// + /// Gets the IDE type this annotation is for (e.g., "vscode"). If , applies to all IDEs. + /// + string? IdeType { get; } + + /// + /// Configures the debugger properties if the runtime type matches the expected type. + /// + /// The debugger properties to configure. + void ConfigureDebuggerProperties(DebugAdapterProperties debuggerProperties); +} + +/// +/// Marks a resource as containing debugger properties. +/// +/// +/// +/// This annotation indicates that the resource contains a custom debugger configuration. +/// When this annotation is present, the resource will be configured with appropriate debug launch configurations. +/// +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal sealed class ExecutableDebuggerPropertiesAnnotation(Action configureDebugProperties, string? ideType = null) : IDebuggerPropertiesAnnotation + where T : DebugAdapterProperties +{ + /// + public string? IdeType { get; } = ideType; + + /// + /// Gets the action to configure the debugger properties. + /// + public Action ConfigureDebuggerPropertiesTyped { get; } = configureDebugProperties; + + /// + public void ConfigureDebuggerProperties(DebugAdapterProperties debuggerProperties) + { + if (debuggerProperties is T typed) + { + ConfigureDebuggerPropertiesTyped(typed); + } + } +} diff --git a/src/Aspire.Hosting/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/ExecutableLaunchConfiguration.cs new file mode 100644 index 00000000000..c7845f25368 --- /dev/null +++ b/src/Aspire.Hosting/ExecutableLaunchConfiguration.cs @@ -0,0 +1,278 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Hosting.Dcp.Model; + +namespace Aspire.Hosting; + +/// +/// Provides constants and utilities for IDE identification and validation. +/// +/// +/// IDEs that support Aspire debugging should set the environment variable +/// to their IDE type constant when launching the app host. This allows Aspire to validate that IDE-specific +/// debug configuration options are only used when the appropriate IDE is connected. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal static class AspireIde +{ + /// + /// The environment variable name that IDEs should set to identify themselves. + /// + public const string EnvironmentVariableName = "ASPIRE_IDE"; + + /// + /// IDE type constant for Visual Studio Code. + /// + public const string VSCode = "vscode"; + + /// + /// Gets the current IDE type from the environment variable. + /// + /// The IDE type string, or if no IDE has been identified. + public static string? GetCurrentIde() => Environment.GetEnvironmentVariable(EnvironmentVariableName); + + /// + /// Checks if the current IDE matches the expected IDE type. + /// + /// The expected IDE type constant (e.g., ). + /// if the current IDE matches; otherwise, . + public static bool IsCurrentIde(string expectedIde) + { + var currentIde = GetCurrentIde(); + return string.Equals(currentIde, expectedIde, StringComparison.OrdinalIgnoreCase); + } + + internal static bool IsVsCode() => IsCurrentIde(VSCode); +} + +/// +/// Constants for executable launch modes. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal static class ExecutableLaunchMode +{ + /// + /// Run the project under the debugger. + /// + public const string Debug = "Debug"; + + /// + /// Run the project without debugging. + /// + public const string NoDebug = "NoDebug"; +} + +/// +/// Base properties for all executable launch configurations. +/// +/// Launch configuration type indicator. +[IgnoreNullsOnSerialization] +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal abstract class ExecutableLaunchConfiguration(string type) +{ + /// + /// The launch configuration type indicator. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = type; + + /// + /// Specifies the launch mode. Currently supported modes are Debug (run the project under the debugger) and NoDebug (run the project without debugging). + /// + [JsonPropertyName("mode")] + public string Mode { get; set; } = System.Diagnostics.Debugger.IsAttached ? ExecutableLaunchMode.Debug : ExecutableLaunchMode.NoDebug; +} + +/// +/// Base properties for all debug adapters following the Debug Adapter Protocol (DAP). +/// These properties represent the standard DAP launch/attach configuration. +/// +/// +/// The actual DAP-standard configuration is determined by the , , +/// and (for launch requests) the property. Everything else is IDE-specific. +/// See for more information. +/// +[JsonConverter(typeof(DebugAdapterPropertiesJsonConverter))] +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal abstract class DebugAdapterProperties +{ + /// + /// The type of debugger to use for this launch configuration. + /// + [JsonPropertyName("type")] + public abstract string Type { get; set; } + + /// + /// The request type of this launch configuration. Currently, launch and attach are supported. Defaults to launch. + /// + [JsonPropertyName("request")] + public virtual string Request { get; set; } = "launch"; + + /// + /// If , the launch request should launch the program without enabling debugging. + /// This is an optional DAP property that applies only to launch requests. + /// + [JsonPropertyName("noDebug")] + public bool? NoDebug { get; set; } + + /// + /// The user-friendly name to appear in the Debug launch configuration dropdown. + /// While not part of the DAP specification, this is commonly used by IDEs. + /// + [JsonPropertyName("name")] + public abstract string Name { get; set; } + + /// + /// The working directory for the program being debugged. While not part of the DAP specification, + /// this is a common property supported by most debug adapters. + /// + [JsonPropertyName("cwd")] + public abstract string WorkingDirectory { get; init; } +} + +/// +/// Base class for VS Code-specific debugger properties that includes common VS Code debug configuration options. +/// +/// +/// This class extends with VS Code-specific options from +/// . +/// All VS Code debugger property classes should inherit from this class. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal abstract class VSCodeDebuggerPropertiesBase : DebugAdapterProperties +{ + /// + /// Controls how the debug configuration is displayed in the UI. + /// + [JsonPropertyName("presentation")] + public PresentationOptions? Presentation { get; set; } + + /// + /// The label of a task to launch before the start of a debug session. Can be set to ${defaultBuildTask} to use the default build task. + /// + [JsonPropertyName("preLaunchTask")] + public string? PreLaunchTask { get; set; } + + /// + /// The name of a task to launch at the very end of a debug session. + /// + [JsonPropertyName("postDebugTask")] + public string? PostDebugTask { get; set; } + + /// + /// Controls the visibility of the Debug console panel during a debugging session. + /// Possible values: "neverOpen", "openOnSessionStart", "openOnFirstSessionStart". + /// + [JsonPropertyName("internalConsoleOptions")] + public string? InternalConsoleOptions { get; set; } + + /// + /// Allows you to connect to a specified port instead of launching the debug adapter. + /// + [JsonPropertyName("debugServer")] + public int? DebugServer { get; set; } + + /// + /// Specifies an action to take when the program outputs a specific message (e.g., opening a URL in a web browser). + /// + [JsonPropertyName("serverReadyAction")] + public ServerReadyAction? ServerReadyAction { get; set; } +} + +/// +/// Controls the presentation of the debug configuration in the UI. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal class PresentationOptions +{ + /// + /// The order of this item in the debug configuration dropdown. + /// + [JsonPropertyName("order")] + public int? Order { get; set; } + + /// + /// The group this configuration belongs to. + /// + [JsonPropertyName("group")] + public string? Group { get; set; } + + /// + /// Whether this configuration should be hidden from the UI. + /// + [JsonPropertyName("hidden")] + public bool? Hidden { get; set; } +} + +/// +/// Specifies an action to take when the server is ready. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal class ServerReadyAction +{ + /// + /// The kind of action to take. Currently only "openExternally" is supported. + /// + [JsonPropertyName("action")] + public string? Action { get; set; } + + /// + /// The pattern to match in the debug console or integrated terminal output. + /// + [JsonPropertyName("pattern")] + public string? Pattern { get; set; } + + /// + /// The URI format to open. Can include ${port} placeholder. + /// + [JsonPropertyName("uriFormat")] + public string? UriFormat { get; set; } +} + +/// +/// Base class for executable launch configurations that include debugger-specific properties. +/// +/// The type of debugger properties to include. +/// Launch configuration type indicator. +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal abstract class ExecutableLaunchConfigurationWithDebuggerProperties(string type) : ExecutableLaunchConfiguration(type) + where T : DebugAdapterProperties +{ + /// + /// Debugger-specific properties. May be null when running in process execution mode without an IDE. + /// + [JsonPropertyName("debugger_properties")] + public T? DebuggerProperties { get; set; } +} + +/// +/// JSON converter that ensures derived types of are serialized +/// with all their properties, not just the base type properties. +/// +/// +/// This converter enables extensibility by allowing other IDEs to define their own debugger properties +/// classes that inherit from and have all properties serialized correctly. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal sealed class DebugAdapterPropertiesJsonConverter : JsonConverter +{ + /// + public override DebugAdapterProperties? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // The abstract base type cannot be deserialized into a concrete instance. + // Skip the JSON value and return null, allowing round-trip through annotation storage. + reader.Skip(); + return null; + } + + /// + public override void Write(Utf8JsonWriter writer, DebugAdapterProperties value, JsonSerializerOptions options) + { + // Serialize using the actual runtime type to include all derived type properties + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} diff --git a/src/Aspire.Hosting/ProjectLaunchConfiguration.cs b/src/Aspire.Hosting/ProjectLaunchConfiguration.cs new file mode 100644 index 00000000000..3dce261d2be --- /dev/null +++ b/src/Aspire.Hosting/ProjectLaunchConfiguration.cs @@ -0,0 +1,324 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Aspire.Hosting; + +/// +/// Models a runnable debug configuration for a .NET project application. +/// +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +internal class ProjectLaunchConfiguration() : ExecutableLaunchConfigurationWithDebuggerProperties("project") +#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +{ + /// + /// The name of the launch profile to be used for project execution. + /// + [JsonPropertyName("launch_profile")] + public string LaunchProfile { get; set; } = string.Empty; + + /// + /// If set to true, the project will be launched without a launch profile and the value of "launch_profile" parameter is disregarded. + /// + [JsonPropertyName("disable_launch_profile")] + public bool DisableLaunchProfile { get; set; } = false; + + /// + /// Path to the project file for the program that is being launched. + /// + [JsonPropertyName("project_path")] + public string ProjectPath { get; set; } = string.Empty; +} + +/// +/// Models VS Code-specific debugger properties for a C# project made available by the coreclr debug adapter. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal class VSCodeCSharpDebuggerProperties : VSCodeDebuggerPropertiesBase +{ + /// + /// Identifies the type of debugger to use. + /// + [JsonPropertyName("type")] + public override string Type { get; set; } = "coreclr"; + + /// + /// The program (executable or DLL) to debug. + /// + [JsonPropertyName("program")] + public string? Program { get; set; } + + /// + /// Provides the name for the debug configuration that appears in the VS Code dropdown list. + /// + [JsonPropertyName("name")] + public override required string Name { get; set; } + + /// + /// Specifies the current working directory for the debugger, which is the base folder for any relative paths used in code. + /// + [JsonPropertyName("cwd")] + public override required string WorkingDirectory { get; init; } + + /// + /// If you need to stop at the entry point of the target, set this to true. + /// + [JsonPropertyName("stopAtEntry")] + public bool? StopAtEntry { get; set; } + + /// + /// Optionally configure a map of source file paths for when source files are in a different location than when the module was compiled. + /// Example: { "C:\\foo": "/home/me/foo" } + /// + [JsonPropertyName("sourceFileMap")] + public Dictionary? SourceFileMap { get; set; } + + /// + /// You can optionally disable justMyCode by setting it to false. Just My Code is a set of features that makes it easier + /// to focus on debugging your code by hiding some of the details of optimized libraries. + /// + [JsonPropertyName("justMyCode")] + public bool? JustMyCode { get; set; } + + /// + /// The debugger requires the pdb and source code to be exactly the same. To change this and disable the requirement + /// for sources to be the same, set this to false. + /// + [JsonPropertyName("requireExactSource")] + public bool? RequireExactSource { get; set; } + + /// + /// The debugger steps over properties and operators in managed code by default. To change this and enable stepping + /// into properties or operators, set this to false. + /// + [JsonPropertyName("enableStepFiltering")] + public bool? EnableStepFiltering { get; set; } + + /// + /// Configures logging options for the debugger. Can control messages logged to the output window. + /// + [JsonPropertyName("logging")] + public LoggingOptions? Logging { get; set; } + + /// + /// Configuration for connecting to a remote computer using another executable to relay standard input and output. + /// + [JsonPropertyName("pipeTransport")] + public PipeTransportOptions? PipeTransport { get; set; } + + /// + /// If true, when an optimized module loads in the target process, the debugger will ask the Just-In-Time compiler + /// to generate code with optimizations disabled. + /// + [JsonPropertyName("suppressJITOptimizations")] + public bool? SuppressJITOptimizations { get; set; } + + /// + /// Allows customization of how the debugger searches for symbols (.pdb files). + /// + [JsonPropertyName("symbolOptions")] + public SymbolLoadOptions? SymbolOptions { get; set; } + + /// + /// Allows customization of Source Link behavior by URL. Source Link enables downloading source files + /// from URLs embedded in .pdb files. + /// + [JsonPropertyName("sourceLinkOptions")] + public Dictionary? SourceLinkOptions { get; set; } + + /// + /// Specifies the target architecture (x86_64 or arm64). The debugger tries to automatically detect this, + /// but you can override the behavior by setting this. + /// + [JsonPropertyName("targetArchitecture")] + public string? TargetArchitecture { get; set; } + + /// + /// Controls if, on launch, the debugger should check if the computer has a self-signed HTTPS certificate + /// for developing web projects. If unspecified, defaults to true when serverReadyAction is set. + /// + [JsonPropertyName("checkForDevCert")] + public bool? CheckForDevCert { get; set; } + + /// + /// Configures logging options for the C# debugger. + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public class LoggingOptions + { + /// + /// If true, the debugger will log exceptions. + /// + [JsonPropertyName("exceptions")] + public bool? Exceptions { get; set; } + + /// + /// If true, the debugger will log module load events. + /// + [JsonPropertyName("moduleLoad")] + public bool? ModuleLoad { get; set; } + + /// + /// If true, the debugger will log program output. + /// + [JsonPropertyName("programOutput")] + public bool? ProgramOutput { get; set; } + + /// + /// If true, the debugger will log browser standard output. + /// + [JsonPropertyName("browserStdOut")] + public bool? BrowserStdOut { get; set; } + + /// + /// If true, the debugger will log console usage messages. + /// + [JsonPropertyName("consoleUsageMessage")] + public bool? ConsoleUsageMessage { get; set; } + + /// + /// Configuration for diagnostic logging to help diagnose debugger problems. + /// + [JsonPropertyName("diagnosticsLog")] + public DiagnosticsLogOptions? DiagnosticsLog { get; set; } + } + + /// + /// Advanced diagnostic logging options for troubleshooting debugger issues. + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public class DiagnosticsLogOptions + { + /// + /// The path where diagnostic logs should be written. + /// + [JsonPropertyName("path")] + public string? Path { get; set; } + + /// + /// Controls the verbosity of diagnostic logging. + /// + [JsonPropertyName("level")] + public string? Level { get; set; } + } + + /// + /// Configuration for pipe transport to connect to a remote computer. + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public class PipeTransportOptions + { + /// + /// The fully qualified path to the pipe program to use (e.g., "ssh"). + /// + [JsonPropertyName("pipeProgram")] + public required string PipeProgram { get; set; } + + /// + /// Command line arguments passed to the pipe program. + /// + [JsonPropertyName("pipeArgs")] + public string[]? PipeArgs { get; set; } + + /// + /// The full path to the debugger on the target machine. + /// + [JsonPropertyName("debuggerPath")] + public string? DebuggerPath { get; set; } + + /// + /// The working directory for the pipe program. + /// + [JsonPropertyName("pipeCwd")] + public string? PipeCwd { get; set; } + + /// + /// If true, arguments will be quoted. Defaults to true. + /// + [JsonPropertyName("quoteArgs")] + public bool? QuoteArgs { get; set; } + } + + /// + /// Configures how the debugger searches for symbol (.pdb) files. + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public class SymbolLoadOptions + { + /// + /// Array of symbol server URLs or directories to search for .pdb files. + /// + [JsonPropertyName("searchPaths")] + public string[]? SearchPaths { get; set; } + + /// + /// If true, the Microsoft Symbol server (https://msdl.microsoft.com/download/symbols) is added to the search path. + /// + [JsonPropertyName("searchMicrosoftSymbolServer")] + public bool? SearchMicrosoftSymbolServer { get; set; } + + /// + /// If true, the NuGet.org Symbol server (https://symbols.nuget.org/download/symbols) is added to the search path. + /// + [JsonPropertyName("searchNuGetOrgSymbolServer")] + public bool? SearchNuGetOrgSymbolServer { get; set; } + + /// + /// Directory where symbols downloaded from symbol servers should be cached. + /// + [JsonPropertyName("cachePath")] + public string? CachePath { get; set; } + + /// + /// Controls which modules to load symbols for. + /// + [JsonPropertyName("moduleFilter")] + public ModuleFilter? ModuleFilter { get; set; } + } + + /// + /// Filters which modules should have symbols loaded. + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public class ModuleFilter + { + /// + /// Either "loadAllButExcluded" or "loadOnlyIncluded". + /// + [JsonPropertyName("mode")] + public string? Mode { get; set; } + + /// + /// Array of modules to exclude (when mode is "loadAllButExcluded"). Wildcards are supported. + /// + [JsonPropertyName("excludedModules")] + public string[]? ExcludedModules { get; set; } + + /// + /// Array of modules to include (when mode is "loadOnlyIncluded"). Wildcards are supported. + /// + [JsonPropertyName("includedModules")] + public string[]? IncludedModules { get; set; } + + /// + /// If true, for modules not in includedModules, the debugger will still check next to the module itself. + /// + [JsonPropertyName("includeSymbolsNextToModules")] + public bool? IncludeSymbolsNextToModules { get; set; } + } + + /// + /// Configures Source Link behavior for a specific URL pattern. + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public class SourceLinkOption + { + /// + /// Whether Source Link is enabled for this URL pattern. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + } +} diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index e78aa07fd79..48d240b04fc 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -6,7 +6,6 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dashboard; -using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -245,7 +244,7 @@ public static IResourceBuilder AddProject(this IDistributedAppl var project = new ProjectResource(name); return builder.AddResource(project) .WithAnnotation(projectMetadata) - .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = projectMetadata.ProjectPath, Mode = mode }, "project") + .WithVSCodeDebugging(projectMetadata.ProjectPath) .WithProjectDefaults(options); } @@ -290,7 +289,7 @@ public static IResourceBuilder AddProject(this IDistributedAppl return builder.AddResource(project) .WithAnnotation(new ProjectMetadata(projectPath)) - .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = projectPath, Mode = mode }, "project") + .WithVSCodeDebugging(projectPath) .WithProjectDefaults(options); } @@ -371,7 +370,7 @@ public static IResourceBuilder AddCSharpApp(this IDistributed var resource = builder.AddResource(app) .WithAnnotation(projectMetadata) - .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = projectMetadata.ProjectPath, Mode = mode }, "project") + .WithVSCodeDebugging(projectMetadata.ProjectPath) .WithProjectDefaults(options); resource.OnBeforeResourceStarted(async (r, e, ct) => @@ -859,6 +858,109 @@ public static IResourceBuilder PublishAsDockerFile(this IResourceBuilder + /// Configures debugging support for the project resource. + /// + /// The type of the project resource. + /// The resource builder. + /// The path to the project file. + /// A reference to the . + /// + /// This method configures the project resource to support debugging via the Aspire debugger. + /// It sets up the necessary launch configuration based on the provided project path. This method is only necessary for inheritors of ProjectResource. + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDebugging(this IResourceBuilder builder, string projectPath) + where TProjectResource : ProjectResource + { + return builder.WithVSCodeDebugging(projectPath); + } + + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder, string projectPath) + where TProjectResource : ProjectResource + { + return builder.WithDebugSupport(options => + { + var configuration = new ProjectLaunchConfiguration + { + ProjectPath = projectPath, + Mode = options.Mode, + DebuggerProperties = GetVSCodeCSharpDebuggerProperties(projectPath, options.Mode, builder.ApplicationBuilder.Configuration), + }; + + options.AdditionalConfiguration?.Invoke(configuration); + + // Apply any custom debugger property annotations + if (builder.Resource.TryGetAnnotationsOfType(out var annotations)) + { + foreach (var annotation in annotations) + { + // Filter by IDE type if specified, and by debugger properties type + if (annotation.IdeType is null || AspireIde.IsCurrentIde(annotation.IdeType)) + { + annotation.ConfigureDebuggerProperties(configuration.DebuggerProperties); + } + } + } + + return configuration; + }, "project"); + } + + internal static VSCodeCSharpDebuggerProperties GetVSCodeCSharpDebuggerProperties(string projectPath, string mode, IConfiguration configuration) + { + var workspaceRoot = configuration[KnownConfigNames.ExtensionWorkspaceRoot]; + var displayProgramPath = workspaceRoot is not null + ? Path.GetRelativePath(workspaceRoot, projectPath) + : projectPath; + var modeText = mode == "Debug" ? "Debug" : "Run"; + + return new VSCodeCSharpDebuggerProperties + { + WorkingDirectory = Path.GetDirectoryName(projectPath) ?? projectPath, + Name = $"{modeText} C#: {displayProgramPath}", + Program = projectPath + }; + } + + /// + /// Configures VS Code-specific C# debugger properties for the project resource. + /// + /// The type of the project resource. + /// The resource builder. + /// An action to configure the C# debugger properties. + /// A reference to the . + /// + /// + /// This method allows customization of the VS Code debugger configuration that will be used when debugging the resource. + /// The callback receives an object that is pre-populated with default values based on the resource's configuration. + /// You can modify any properties to customize the debugging experience. + /// + /// + /// + /// Configure C# debugger to stop on entry: + /// + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithVSCodeCSharpDebuggerProperties(props => + /// { + /// props.StopAtEntry = true; // Stop execution at entrypoint + /// }) + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeCSharpDebuggerProperties( + this IResourceBuilder builder, + Action configureDebuggerProperties) + where T : ProjectResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureDebuggerProperties); + + builder.WithAnnotation(new ExecutableDebuggerPropertiesAnnotation(configureDebuggerProperties, AspireIde.VSCode)); + return builder; + } + private static IConfiguration GetConfiguration(ProjectResource projectResource) { var projectMetadata = projectResource.GetProjectMetadata(); diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 1b801a83db9..6914364fe98 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3072,39 +3072,6 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde return builder; } - /// - /// Adds support for debugging the resource in VS Code when running in an extension host. - /// - /// The resource builder. - /// Launch configuration producer for the resource. - /// The type of the resource. - /// Optional callback to add or modify command line arguments when running in an extension host. Useful if the entrypoint is usually provided as an argument to the resource executable. - [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - public static IResourceBuilder WithDebugSupport(this IResourceBuilder builder, Func launchConfigurationProducer, string launchConfigurationType, Action? argsCallback = null) - where T : IResource - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(launchConfigurationProducer); - - if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) - { - return builder; - } - - if (builder is IResourceBuilder resourceWithArgs) - { - resourceWithArgs.WithArgs(async ctx => - { - if (resourceWithArgs.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out _) && argsCallback is not null) - { - argsCallback(ctx); - } - }); - } - - return builder.WithAnnotation(SupportsDebuggingAnnotation.Create(launchConfigurationType, launchConfigurationProducer)); - } - /// /// Adds a HTTP probe to the resource. /// @@ -3383,4 +3350,118 @@ public static IResourceBuilder WithRemoteImageTag( context.Options.RemoteImageTag = remoteImageTag; }); } + + /// + /// Adds support for debugging the resource in VS Code when running in an extension host. + /// + /// The resource builder. + /// Launch configuration producer for the resource. + /// The capability (such as extension) that this configuration supports. + /// Optional callback to add or modify command line arguments when running in an extension host. Useful if the entrypoint is usually provided as an argument to the resource executable. + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithDebugSupport(this IResourceBuilder builder, Func launchConfigurationProducer, string launchConfigurationType, Action? argsCallback = null) + where T : IResource + where TLaunchConfiguration : ExecutableLaunchConfiguration + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(launchConfigurationProducer); + + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder; + } + + if (builder is IResourceBuilder resourceWithArgs && argsCallback is not null) + { + resourceWithArgs.WithArgs(ctx => + { + if (resourceWithArgs.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out _)) + { + argsCallback(ctx); + } + }); + } + + return builder.WithAnnotation(SupportsDebuggingAnnotation.Create(launchConfigurationType, launchConfigurationProducer)); + } + + /// + /// Configures custom debugger properties for a resource. + /// + /// The type of the resource. + /// The type of the debugger properties. + /// The resource builder. + /// A callback action to configure the debugger properties. + /// A reference to the for method chaining. + /// + /// + /// This method allows customization of the debugger configuration that will be used when debugging the resource + /// in VS Code or Visual Studio. The callback receives an object + /// that is pre-populated with default values based on the resource's configuration. You can modify any properties + /// to customize the debugging experience. + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithDebuggerProperties( + this IResourceBuilder builder, Action configureDebuggerProperties) + where T : IResource + where TDebuggerProperties : DebugAdapterProperties + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureDebuggerProperties); + + builder.WithAnnotation(new ExecutableDebuggerPropertiesAnnotation(configureDebuggerProperties)); + return builder; + } + + /// + /// Configures VS Code-specific debug options for a resource, validating that the current IDE is VS Code. + /// + /// The type of the resource. + /// The type of the debugger properties. + /// The resource builder. + /// A callback action to configure VS Code-specific debug options. + /// A reference to the for method chaining. + /// + /// Thrown when the current IDE is not VS Code (i.e., the + /// environment variable is not set to ). + /// + /// + /// + /// This method allows configuration of VS Code-specific debug options such as , + /// , and . + /// + /// + /// The configuration callback receives the VS Code debugger properties directly, allowing you to set any + /// VS Code-specific options on the debugger configuration. + /// + /// + /// + /// Configure VS Code-specific options for a project: + /// + /// builder.AddProject<Projects.MyApi>("api") + /// .WithVSCodeDebugOptions<ProjectResource, VSCodeCSharpDebuggerProperties>(props => + /// { + /// props.PreLaunchTask = "${defaultBuildTask}"; + /// props.ServerReadyAction = new ServerReadyAction + /// { + /// Action = "openExternally", + /// Pattern = "Now listening on: (https?://\\S+)" + /// }; + /// }); + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeDebugOptions( + this IResourceBuilder builder, Action configureVSCodeOptions) + where T : IResource + where TDebuggerProperties : VSCodeDebuggerPropertiesBase + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureVSCodeOptions); + + builder.WithAnnotation(new ExecutableDebuggerPropertiesAnnotation(configureVSCodeOptions, AspireIde.VSCode)); + + return builder; + } } diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index 8802364b516..a348b1f1487 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.Dcp.Model; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.ApplicationModel; @@ -14,20 +15,57 @@ namespace Aspire.Hosting.ApplicationModel; [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] internal sealed class SupportsDebuggingAnnotation : IResourceAnnotation { - private SupportsDebuggingAnnotation(string launchConfigurationType, Action launchConfigurationAnnotator) + private SupportsDebuggingAnnotation(string launchConfigurationType, Action launchConfigurationAnnotator) { LaunchConfigurationType = launchConfigurationType; LaunchConfigurationAnnotator = launchConfigurationAnnotator; } + /// + /// Gets the type of launch configuration supported by the resource. + /// public string LaunchConfigurationType { get; } - public Action LaunchConfigurationAnnotator { get; } - internal static SupportsDebuggingAnnotation Create(string launchConfigurationType, Func launchProfileProducer) + /// + /// Gets the action that annotates the launch configuration for the resource. + /// + /// Whether the annotation was applied successfully. If this throws, the resource will not be launched in IDE + internal Action LaunchConfigurationAnnotator { get; } + + /// + /// Creates a new instance of the class. + /// + /// The type of the launch configuration. + /// The type of launch configuration supported by the resource. + /// The function that produces the launch configuration for the resource. + public static SupportsDebuggingAnnotation Create(string launchConfigurationType, Func launchProfileProducer) + where T : ExecutableLaunchConfiguration { - return new SupportsDebuggingAnnotation(launchConfigurationType, (exe, mode) => + return new SupportsDebuggingAnnotation(launchConfigurationType, (exe, options) => { - exe.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, launchProfileProducer(mode)); + exe.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, launchProfileProducer(options)); }); } -} \ No newline at end of file +} + +/// +/// Provides options for producing launch configurations for debugging resources. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal sealed class LaunchConfigurationProducerOptions +{ + /// + /// The mode for the launch configuration. Possible values include Debug or NoDebug. + /// + public required string Mode { get; init; } + + /// + /// The logger used for debug console output. + /// + public required ILogger DebugConsoleLogger { get; init; } + + /// + /// Internal hook to allow further configuration of the launch configuration after creation, only for project resources. + /// + internal Action? AdditionalConfiguration { get; set; } +} diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index d1b8af9fe23..81ef3b3b022 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -46,6 +46,7 @@ internal static class KnownConfigNames public const string ExtensionToken = "ASPIRE_EXTENSION_TOKEN"; public const string ExtensionCert = "ASPIRE_EXTENSION_CERT"; public const string ExtensionDebugSessionId = "ASPIRE_EXTENSION_DEBUG_SESSION_ID"; + public const string ExtensionWorkspaceRoot = "ASPIRE_EXTENSION_WORKSPACE_ROOT"; public const string DeveloperCertificateDefaultTrust = "ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_TRUST"; public const string DeveloperCertificateDefaultHttpsTermination = "ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_HTTPS_TERMINATION"; diff --git a/src/Aspire.Hosting/Utils/ExtensionUtils.cs b/src/Shared/ResourceDebugSupportExtensions.cs similarity index 63% rename from src/Aspire.Hosting/Utils/ExtensionUtils.cs rename to src/Shared/ResourceDebugSupportExtensions.cs index 62efc6b5f9e..a17e21e8423 100644 --- a/src/Aspire.Hosting/Utils/ExtensionUtils.cs +++ b/src/Shared/ResourceDebugSupportExtensions.cs @@ -1,29 +1,31 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; -using Aspire.Hosting.Dcp.Model; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; -namespace Aspire.Hosting.Utils; +namespace Aspire.Hosting; #pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -internal static class ExtensionUtils +internal static class ResourceDebugSupportExtensions { public static bool SupportsDebugging(this IResource builder, IConfiguration configuration, [NotNullWhen(true)] out SupportsDebuggingAnnotation? supportsDebuggingAnnotation) { var supportedLaunchConfigurations = GetSupportedLaunchConfigurations(configuration); return builder.TryGetLastAnnotation(out supportsDebuggingAnnotation) - && !string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar]) + && !string.IsNullOrEmpty(configuration["DEBUG_SESSION_PORT"]) && ((supportedLaunchConfigurations is null && supportsDebuggingAnnotation.LaunchConfigurationType == "project") // per DCP spec, project resources support debugging if no launch configurations are specified || (supportedLaunchConfigurations is not null && supportedLaunchConfigurations.Contains(supportsDebuggingAnnotation.LaunchConfigurationType))); } - private static string[]? GetSupportedLaunchConfigurations(IConfiguration configuration) + internal static string[]? GetSupportedLaunchConfigurations(this IConfiguration configuration) { try { @@ -38,4 +40,14 @@ public static bool SupportsDebugging(this IResource builder, IConfiguration conf return null; } + + internal sealed class RunSessionInfo + { + [JsonPropertyName("protocols_supported")] + public required string[] ProtocolsSupported { get; set; } + + [JsonPropertyName("supported_launch_configurations")] + public string[]? SupportedLaunchConfigurations { get; set; } + } } +#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs index f811f730866..1e05f786cfe 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs @@ -418,4 +418,220 @@ public async Task VerifyNodeAppWithContainerFilesFromResourceWithDashesGenerates private sealed class MyFilesContainer(string name, string command, string workingDirectory) : ExecutableResource(name, command, workingDirectory), IResourceWithContainerFiles; + + #region Debug Support Tests + +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only + + [Fact] + public void NodeApp_WithVSCodeDebugging_AddsSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var nodeApp = builder.AddNodeApp("nodeapp", tempDir.Path, "app.js"); + + var annotation = nodeApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("node", annotation.LaunchConfigurationType); + } + + [Fact] + public void NodeApp_WithVSCodeDebugging_DoesNotAddAnnotationInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TestTempDirectory(); + + var nodeApp = builder.AddNodeApp("nodeapp", tempDir.Path, "app.js"); + + var annotation = nodeApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.Null(annotation); + } + + [Fact] + public void NodeApp_WithVSCodeNodeDebuggerProperties_AddsAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var nodeApp = builder.AddNodeApp("nodeapp", tempDir.Path, "app.js") + .WithVSCodeNodeDebuggerProperties(props => + { + props.StopOnEntry = true; + props.SmartStep = true; + }); + + var annotation = nodeApp.Resource.Annotations.OfType>().SingleOrDefault(); + Assert.NotNull(annotation); + } + + [Fact] + public void ViteApp_WithVSCodeDebugging_AddsSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var viteApp = builder.AddViteApp("viteapp", tempDir.Path); + + var annotation = viteApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("node", annotation.LaunchConfigurationType); + } + + [Fact] + public void ViteApp_WithBrowserDebugger_CreatesChildResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var viteApp = builder.AddViteApp("viteapp", tempDir.Path) + .WithBrowserDebugger(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var browserDebuggerResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(browserDebuggerResource); + Assert.Equal("viteapp-browser", browserDebuggerResource.Name); + + // Verify parent relationship + Assert.True(browserDebuggerResource.TryGetAnnotationsOfType(out var relationships)); + var parentRelationship = Assert.Single(relationships, r => r.Type == "Parent"); + Assert.Same(viteApp.Resource, parentRelationship.Resource); + + // Verify supports debugging annotation + var annotation = browserDebuggerResource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("browser", annotation.LaunchConfigurationType); + } + + [Fact] + public void ViteApp_WithBrowserDebugger_DefaultsToEdgeBrowser() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var viteApp = builder.AddViteApp("viteapp", tempDir.Path) + .WithBrowserDebugger(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var browserDebuggerResource = appModel.Resources.OfType().Single(); + Assert.Equal("msedge", browserDebuggerResource.DebuggerProperties.Type); + } + + [Fact] + public void ViteApp_WithBrowserDebugger_UsesSpecifiedBrowser() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var viteApp = builder.AddViteApp("viteapp", tempDir.Path) + .WithBrowserDebugger(browser: "chrome"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var browserDebuggerResource = appModel.Resources.OfType().Single(); + Assert.Equal("chrome", browserDebuggerResource.DebuggerProperties.Type); + } + + [Fact] + public void ViteApp_WithBrowserDebugger_ConfiguresDebuggerProperties() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var viteApp = builder.AddViteApp("viteapp", tempDir.Path) + .WithBrowserDebugger("msedge", configureDebuggerProperties: props => + { + props.SmartStep = true; + props.Timeout = 30000; + }); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var browserDebuggerResource = appModel.Resources.OfType().Single(); + Assert.True(browserDebuggerResource.DebuggerProperties.SmartStep); + Assert.Equal(30000, browserDebuggerResource.DebuggerProperties.Timeout); + Assert.Equal(tempDir.Path, browserDebuggerResource.DebuggerProperties.WebRoot); + } + + [Fact] + public void ViteApp_WithBrowserDebugger_WithoutEndpoint_ThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + // Create a minimal JavaScriptAppResource without endpoints + var resource = new JavaScriptAppResource("jsapp", "npm", tempDir.Path); + var jsApp = builder.AddResource(resource); + + var exception = Assert.Throws(() => + jsApp.WithBrowserDebugger()); + + Assert.Contains("does not have an HTTP or HTTPS endpoint", exception.Message); + } + + [Fact] + public void NodeApp_WithVSCodeDebugging_MultipleCallsAccumulateAnnotations() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var nodeApp = builder.AddNodeApp("nodeapp", tempDir.Path, "app.js"); + + // AddNodeApp already calls WithVSCodeDebugging, so calling WithDebugging again adds another annotation + nodeApp.WithDebugging("server.js"); + + var annotations = nodeApp.Resource.Annotations.OfType().ToList(); + Assert.Equal(2, annotations.Count); + Assert.All(annotations, a => Assert.Equal("node", a.LaunchConfigurationType)); + } + + [Fact] + public void ViteApp_WithVSCodeDebugging_DefaultsSkipFilesAndSourceMaps() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var viteApp = builder.AddViteApp("viteapp", tempDir.Path) + .WithVSCodeNodeDebuggerProperties(props => + { + // Verify defaults were set on the properties object + Assert.NotNull(props.SkipFiles); + Assert.Contains("/**", props.SkipFiles); + Assert.True(props.SourceMaps); + Assert.True(props.AutoAttachChildProcesses); + }); + + var annotation = viteApp.Resource.Annotations.OfType>().SingleOrDefault(); + Assert.NotNull(annotation); + } + + [Fact] + public void NodeApp_WithVSCodeNodeDebuggerProperties_MultipleCallsAccumulate() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var nodeApp = builder.AddNodeApp("nodeapp", tempDir.Path, "app.js") + .WithVSCodeNodeDebuggerProperties(props => + { + props.StopOnEntry = true; + }) + .WithVSCodeNodeDebuggerProperties(props => + { + props.SmartStep = true; + }); + + var annotations = nodeApp.Resource.Annotations.OfType>().ToList(); + Assert.Equal(2, annotations.Count); + } + +#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only + + #endregion } diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 85f81e51b97..efec44762b7 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -12,8 +12,8 @@ using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; using System.Text.Json; -using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Eventing; +using System.Text.Json.Serialization; namespace Aspire.Hosting.Python.Tests; @@ -367,7 +367,7 @@ private static void AssertPythonCommandPath(string expectedVenvPath, string actu var expectedCommand = OperatingSystem.IsWindows() ? Path.Join(expectedVenvPath, "Scripts", "python.exe") : Path.Join(expectedVenvPath, "bin", "python"); - + Assert.Equal(expectedCommand, actualCommand); } @@ -594,7 +594,7 @@ public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere() public void WithVirtualEnvironment_PrefersAppDirectoryWhenVenvExistsInBoth() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); - + // Create app directory as a subdirectory of AppHost (realistic scenario) var appDirName = "python-app"; var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName); @@ -663,7 +663,7 @@ public void WithVirtualEnvironment_DefaultsToAppDirectoryWhenVenvExistsInNeither public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); - + // Create app directory as a subdirectory of AppHost var appDirName = "python-app"; var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName); @@ -680,7 +680,7 @@ public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim() try { var scriptName = "main.py"; - + // Explicitly specify a custom venv path - should use it verbatim, not fall back to AppHost .venv var resourceBuilder = builder.AddPythonApp("pythonProject", appDirName, scriptName) .WithVirtualEnvironment("custom-venv"); @@ -2390,5 +2390,169 @@ private static async Task PublishBeforeStartEventAsync(DistributedApplication ap var eventing = app.Services.GetRequiredService(); await eventing.PublishAsync(new BeforeStartEvent(app.Services, appModel), CancellationToken.None); } + + #region Debug Support Tests + +#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only + + [Fact] + public void PythonApp_InRunMode_AddsSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var pythonApp = builder.AddPythonApp("pythonapp", tempDir.Path, "main.py") + .WithVirtualEnvironment(Path.Combine(tempDir.Path, ".venv")); + + var annotation = pythonApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("python", annotation.LaunchConfigurationType); + } + + [Fact] + public void PythonApp_InPublishMode_DoesNotAddSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TestTempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var pythonApp = builder.AddPythonApp("pythonapp", tempDir.Path, "main.py") + .WithVirtualEnvironment(Path.Combine(tempDir.Path, ".venv")); + + var annotation = pythonApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.Null(annotation); + } + + [Fact] + public void PythonApp_WithVSCodePythonDebuggerProperties_AddsAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var pythonApp = builder.AddPythonApp("pythonapp", tempDir.Path, "main.py") + .WithVirtualEnvironment(Path.Combine(tempDir.Path, ".venv")) + .WithVSCodePythonDebuggerProperties(props => + { + props.Jinja = false; + props.StopOnEntry = true; + }); + + var annotation = pythonApp.Resource.Annotations.OfType>().SingleOrDefault(); + Assert.NotNull(annotation); + } + + [Fact] + public void PythonModule_InRunMode_AddsSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var pythonApp = builder.AddPythonModule("pythonapp", tempDir.Path, "flask") + .WithVirtualEnvironment(Path.Combine(tempDir.Path, ".venv")); + + var annotation = pythonApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("python", annotation.LaunchConfigurationType); + } + + [Fact] + public void PythonExecutable_InRunMode_WithVSCodeDebugging_AddsSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + // Unlike scripts and modules, Python executables do not have debugging support by default + // Users must explicitly call WithVSCodeDebugging() + var pythonApp = builder.AddPythonExecutable("pythonapp", tempDir.Path, "myexe") + .WithVirtualEnvironment(Path.Combine(tempDir.Path, ".venv")) + .WithVSCodeDebugging(); + + var annotation = pythonApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("python", annotation.LaunchConfigurationType); + } + + [Fact] + public void PythonApp_WithVSCodePythonDebuggerProperties_MultipleCallsAccumulate() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var pythonApp = builder.AddPythonApp("pythonapp", tempDir.Path, "main.py") + .WithVirtualEnvironment(Path.Combine(tempDir.Path, ".venv")) + .WithVSCodePythonDebuggerProperties(props => + { + props.Jinja = false; + }) + .WithVSCodePythonDebuggerProperties(props => + { + props.StopOnEntry = true; + }); + + var annotations = pythonApp.Resource.Annotations + .OfType>() + .ToList(); + Assert.Equal(2, annotations.Count); + } + + [Fact] + public void PythonApp_InPublishMode_WithDebugging_DoesNotAddAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TestTempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var pythonApp = builder.AddPythonApp("pythonapp", tempDir.Path, "main.py") + .WithVirtualEnvironment(Path.Combine(tempDir.Path, ".venv")) + .WithDebugging(); + + var annotation = pythonApp.Resource.Annotations.OfType().SingleOrDefault(); + // WithDebugging in publish mode should not add annotation (early return in WithDebugSupport) + // The only annotation present is from AddPythonApp itself which also calls WithVSCodeDebugging + // which also returns early in publish mode + Assert.Null(annotation); + } + + [Fact] + public void PythonModule_InRunMode_WithDebugging_AddsSupportsDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TestTempDirectory(); + + var pythonApp = builder.AddPythonModule("pythonapp", tempDir.Path, "flask") + .WithVirtualEnvironment(Path.Combine(tempDir.Path, ".venv")) + .WithDebugging(); + + var annotations = pythonApp.Resource.Annotations.OfType().ToList(); + // AddPythonModule + explicit WithDebugging = 2 annotations + Assert.Equal(2, annotations.Count); + Assert.All(annotations, a => Assert.Equal("python", a.LaunchConfigurationType)); + } + +#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only + + #endregion + + internal sealed class RunSessionInfo + { + [JsonPropertyName("protocols_supported")] + public required string[] ProtocolsSupported { get; set; } + + [JsonPropertyName("supported_launch_configurations")] + public string[]? SupportedLaunchConfigurations { get; set; } + } } diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 8ccc1004794..bf4dda9bfb8 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -8,6 +8,7 @@ using System.Text; using System.Text.Json; using System.Threading.Channels; +using Aspire.Hosting.Backchannel; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Tests.Utils; @@ -17,10 +18,12 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Polly; using Polly.Retry; +using static Aspire.Hosting.ResourceDebugSupportExtensions; namespace Aspire.Hosting.Tests.Dcp; @@ -1655,7 +1658,7 @@ public async Task PlainExecutable_ExtensionMode_SupportedDebugMode_RunsInIde() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(options => new CustomExecutableLaunchConfiguration("test") { Mode = options.Mode }, "test"); var nonDebuggableExecutable = new TestOtherExecutableResource("test-working-directory-2"); // No SupportsDebuggingAnnotation for this one @@ -1692,7 +1695,7 @@ public async Task PlainExecutable_ExtensionMode_SupportedDebugMode_RunsInIde() var debuggableExe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable"); Assert.Equal(ExecutionType.IDE, debuggableExe.Spec.ExecutionType); - Assert.True(debuggableExe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var launchConfigs1)); + Assert.True(debuggableExe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var launchConfigs1)); var config1 = Assert.Single(launchConfigs1); Assert.Equal(ExecutableLaunchMode.Debug, config1.Mode); Assert.Equal("test", config1.Type); @@ -1710,7 +1713,7 @@ public async Task PlainExecutable_ExtensionMode_UnsupportedDebugMode_RunsInProce // Create executable resources with SupportsDebuggingAnnotation var executable = new TestExecutableResource("test-working-directory"); - builder.AddResource(executable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(executable).WithDebugSupport(_ => new CustomExecutableLaunchConfiguration("test"), "test"); // Simulate debug session port and extension endpoint (extension mode) var configDict = new Dictionary @@ -1746,7 +1749,7 @@ public async Task PlainExecutable_NoExtensionMode_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new CustomExecutableLaunchConfiguration("test"), "test"); var nonDebuggableExecutable = new TestOtherExecutableResource("test-working-directory-2"); builder.AddResource(nonDebuggableExecutable); @@ -1788,7 +1791,7 @@ public async Task CustomExecutable_NoDebugSessionInfo_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new CustomExecutableLaunchConfiguration("test"), "test"); // Simulate no debug session port and no extension endpoint (no debug session info) var configDict = new Dictionary @@ -1824,7 +1827,7 @@ public async Task CustomExecutable_InvalidDebugSessionInfo_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new CustomExecutableLaunchConfiguration("test"), "test"); // Simulate debug session port with invalid JSON in DebugSessionInfo var configDict = new Dictionary @@ -1860,7 +1863,7 @@ public async Task CustomExecutable_DebugSessionInfoWithNullSupportedLaunchConfig // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new CustomExecutableLaunchConfiguration("test"), "test"); // Simulate debug session info with null SupportedLaunchConfigurations var runSessionInfo = new RunSessionInfo @@ -1902,7 +1905,7 @@ public async Task CustomExecutable_DebugSessionInfoNotContainingType_RunInProces // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new CustomExecutableLaunchConfiguration("test"), "test"); // Simulate debug session info with SupportedLaunchConfigurations that do not match the executable type var runSessionInfo = new RunSessionInfo @@ -1944,7 +1947,7 @@ public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new CustomExecutableLaunchConfiguration("test"), "test"); // Simulate debug session info with SupportedLaunchConfigurations that match the executable type var runSessionInfo = new RunSessionInfo @@ -2283,6 +2286,98 @@ private static void HasKnownCommandAnnotations(IResource resource) a => Assert.Equal(KnownResourceCommands.RestartCommand, a.Name)); } + [Fact] + public async Task PlainExecutable_LaunchConfigurationProducerThrows_FallsBackToProcess() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + + // Create executable resource with SupportsDebuggingAnnotation that throws + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => throw new InvalidOperationException("Test exception from launch configuration producer"), "test"); + + // Simulate debug session info with SupportedLaunchConfigurations that match the executable type + var runSessionInfo = new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }; + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + // Act + await appExecutor.RunApplicationAsync(); + + // Assert + var dcpExes = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Single(dcpExes); + + var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable"); + // Should fall back to Process execution when the launch configuration producer throws + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + } + + [Fact] + public async Task ProjectExecutable_LaunchConfigurationProducerThrows_FallsBackToProcess() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName + }); + + var project = builder.AddProject("ServiceA"); + + // Add a SupportsDebuggingAnnotation that throws to override the default project debugging + project.WithAnnotation(SupportsDebuggingAnnotation.Create( + "project", + _ => throw new InvalidOperationException("Test exception from project launch configuration producer"))); + + // Simulate debug session info with project support + var runSessionInfo = new RunSessionInfo + { + ProtocolsSupported = ["project"], + SupportedLaunchConfigurations = ["project"] + }; + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + // Act + await appExecutor.RunApplicationAsync(); + + // Assert + var dcpExes = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Single(dcpExes); + + var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "ServiceA"); + // Should fall back to Process execution when the launch configuration producer throws + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + } + private static DcpExecutor CreateAppExecutor( DistributedApplicationModel distributedAppModel, IHostEnvironment? hostEnvironment = null, @@ -2333,7 +2428,8 @@ private static DcpExecutor CreateAppExecutor( new DcpNameGenerator(configuration, Options.Create(dcpOptions)), events ?? new DcpExecutorEvents(), new Locations(new FileSystemService(configuration ?? new ConfigurationBuilder().Build())), - developerCertificateService); + developerCertificateService, + new FakeBackchannelLoggerProvider()); #pragma warning restore ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } @@ -2428,4 +2524,18 @@ private sealed class CustomChildResource(string name, IResource parent) : Resour { public IResource Parent => parent; } + + private sealed class CustomExecutableLaunchConfiguration(string type) : ExecutableLaunchConfiguration(type); + + private sealed class FakeBackchannelLoggerProvider : IBackchannelLoggerProvider + { + public ILogger CreateLogger(string categoryName) + { + return NullLogger.Instance; + } + + public void Dispose() + { + } + } } diff --git a/tests/Aspire.Hosting.Tests/DebuggerPropertiesAnnotationTests.cs b/tests/Aspire.Hosting.Tests/DebuggerPropertiesAnnotationTests.cs new file mode 100644 index 00000000000..56642e8eae3 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/DebuggerPropertiesAnnotationTests.cs @@ -0,0 +1,966 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREEXTENSION001 // Experimental extension APIs +#pragma warning disable IDE0005 // Remove unnecessary using + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +namespace Aspire.Hosting.Tests; + +public class DebuggerPropertiesAnnotationTests +{ + #region Test IDE Debugger Properties + + // Simulates VS Code debugger properties - inherits from VSCodeDebuggerPropertiesBase + // which has VS Code-specific properties (Presentation, PreLaunchTask, etc.) directly on it + [Experimental("ASPIREEXTENSION001")] + private sealed class TestVSCodeDebuggerProperties : VSCodeDebuggerPropertiesBase + { + public override string Type { get; set; } = "coreclr"; + public override required string Name { get; set; } + public override required string WorkingDirectory { get; init; } + public bool WasConfigured { get; set; } + public string? VSCodeSpecificValue { get; set; } + } + + // Simulates another IDE's debugger properties - inherits from DebugAdapterProperties (no VS Code options) + [Experimental("ASPIREEXTENSION001")] + private sealed class TestOtherIdeDebuggerProperties : DebugAdapterProperties + { + public override string Type { get; set; } = "dotnet"; + public override required string Name { get; set; } + public override required string WorkingDirectory { get; init; } + public bool WasConfigured { get; set; } + public string? OtherIdeSpecificValue { get; set; } + } + + // Simulates Visual Studio debugger properties - inherits from DebugAdapterProperties (no VS Code options) + [Experimental("ASPIREEXTENSION001")] + private sealed class TestVisualStudioDebuggerProperties : DebugAdapterProperties + { + public override string Type { get; set; } = "managed"; + public override required string Name { get; set; } + public override required string WorkingDirectory { get; init; } + public bool WasConfigured { get; set; } + public string? VisualStudioSpecificValue { get; set; } + } + + #endregion + + [Fact] + public void ConfigureDebuggerProperties_OnlyConfiguresMatchingType() + { + // Arrange - Create annotation for VS Code + var annotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.WasConfigured = true; + props.VSCodeSpecificValue = "configured"; + }); + + // Create properties for different IDEs + var vsCodeProps = new TestVSCodeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + var otherIdeProps = new TestOtherIdeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + var vsProps = new TestVisualStudioDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + + // Act - Try to configure each type + annotation.ConfigureDebuggerProperties(vsCodeProps); + annotation.ConfigureDebuggerProperties(otherIdeProps); + annotation.ConfigureDebuggerProperties(vsProps); + + // Assert - Only VS Code props should be configured + Assert.True(vsCodeProps.WasConfigured); + Assert.Equal("configured", vsCodeProps.VSCodeSpecificValue); + + Assert.False(otherIdeProps.WasConfigured); + Assert.Null(otherIdeProps.OtherIdeSpecificValue); + + Assert.False(vsProps.WasConfigured); + Assert.Null(vsProps.VisualStudioSpecificValue); + } + + [Fact] + public void MultipleAnnotations_EachConfiguresOnlyMatchingType() + { + // Arrange - Create annotations for each IDE + var vsCodeAnnotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.WasConfigured = true; + props.VSCodeSpecificValue = "vscode-value"; + }); + + var otherIdeAnnotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.WasConfigured = true; + props.OtherIdeSpecificValue = "other-ide-value"; + }); + + var vsAnnotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.WasConfigured = true; + props.VisualStudioSpecificValue = "vs-value"; + }); + + var annotations = new IDebuggerPropertiesAnnotation[] { vsCodeAnnotation, otherIdeAnnotation, vsAnnotation }; + + // Scenario 1: VS Code IDE creates VS Code properties + var vsCodeProps = new TestVSCodeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(vsCodeProps); + } + + Assert.True(vsCodeProps.WasConfigured); + Assert.Equal("vscode-value", vsCodeProps.VSCodeSpecificValue); + + // Scenario 2: Other IDE creates its properties + var otherIdeProps = new TestOtherIdeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(otherIdeProps); + } + + Assert.True(otherIdeProps.WasConfigured); + Assert.Equal("other-ide-value", otherIdeProps.OtherIdeSpecificValue); + + // Scenario 3: Visual Studio IDE creates VS properties + var vsProps = new TestVisualStudioDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(vsProps); + } + + Assert.True(vsProps.WasConfigured); + Assert.Equal("vs-value", vsProps.VisualStudioSpecificValue); + } + + [Fact] + public void Annotation_DoesNotThrow_WhenNonMatchingTypeProvided() + { + // Arrange + var annotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.WasConfigured = true; + }); + + var otherIdeProps = new TestOtherIdeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + + // Act & Assert - Should not throw, just silently skip + var exception = Record.Exception(() => annotation.ConfigureDebuggerProperties(otherIdeProps)); + + Assert.Null(exception); + Assert.False(otherIdeProps.WasConfigured); + } + + [Fact] + public void ConfigureDebuggerPropertiesTyped_DirectlyConfiguresMatchingType() + { + // Arrange + var annotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.WasConfigured = true; + props.VSCodeSpecificValue = "direct"; + }); + + var vsCodeProps = new TestVSCodeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + + // Act - Use the typed method directly + annotation.ConfigureDebuggerPropertiesTyped(vsCodeProps); + + // Assert + Assert.True(vsCodeProps.WasConfigured); + Assert.Equal("direct", vsCodeProps.VSCodeSpecificValue); + } + + [Fact] + public void IDebuggerPropertiesAnnotation_CanBeUsedPolymorphically() + { + // Arrange + IDebuggerPropertiesAnnotation annotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.WasConfigured = true; + props.OtherIdeSpecificValue = "polymorphic"; + }); + + var otherIdeProps = new TestOtherIdeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + var vsCodeProps = new TestVSCodeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + + // Act - Use through the interface + annotation.ConfigureDebuggerProperties(otherIdeProps); + annotation.ConfigureDebuggerProperties(vsCodeProps); + + // Assert + Assert.True(otherIdeProps.WasConfigured); + Assert.Equal("polymorphic", otherIdeProps.OtherIdeSpecificValue); + + Assert.False(vsCodeProps.WasConfigured); + } + + [Fact] + public void SimulateIdeWorkflow_MultipleIdeSupportOnSameResource() + { + // This test simulates how an AppHost developer would configure a resource + // to support multiple IDEs, and how each IDE would consume it + + // Arrange - AppHost developer adds multiple IDE configurations + var annotations = new List + { + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.VSCodeSpecificValue = "stopOnEntry=true"; + }), + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.OtherIdeSpecificValue = "enableExternalSource=true"; + }), + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.VisualStudioSpecificValue = "enableNativeDebugging=false"; + }) + }; + + // Act - Simulate VS Code IDE extension consuming the resource + var vsCodeDebugConfig = new TestVSCodeDebuggerProperties + { + Name = "MyProject", + WorkingDirectory = "/path/to/project" + }; + + // The IDE iterates over all annotations and calls ConfigureDebuggerProperties + // Only matching annotations will have any effect + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(vsCodeDebugConfig); + } + + // Assert - Only VS Code-specific configuration was applied + Assert.Equal("stopOnEntry=true", vsCodeDebugConfig.VSCodeSpecificValue); + + // Act - Simulate another IDE extension consuming the same resource + var otherIdeDebugConfig = new TestOtherIdeDebuggerProperties + { + Name = "MyProject", + WorkingDirectory = "/path/to/project" + }; + + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(otherIdeDebugConfig); + } + + // Assert - Only the other IDE's configuration was applied + Assert.Equal("enableExternalSource=true", otherIdeDebugConfig.OtherIdeSpecificValue); + } + + [Fact] + public void Annotation_ModifiesExistingProperties_NotReplaceThem() + { + // Arrange - Annotation modifies specific properties + var annotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.VSCodeSpecificValue = "modified"; + // Does NOT modify Name or WorkingDirectory + }); + + var props = new TestVSCodeDebuggerProperties + { + Name = "OriginalName", + WorkingDirectory = "/original/path", + Type = "original-type" + }; + + // Act + annotation.ConfigureDebuggerProperties(props); + + // Assert - Only VSCodeSpecificValue was modified, others remain + Assert.Equal("OriginalName", props.Name); + Assert.Equal("/original/path", props.WorkingDirectory); + Assert.Equal("original-type", props.Type); + Assert.Equal("modified", props.VSCodeSpecificValue); + } + + [Fact] + public void AnnotationsAreCumulative_MultipleAnnotationsOfSameType() + { + // Arrange - Multiple annotations for the same IDE type + var annotation1 = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.VSCodeSpecificValue = "first"; + }); + + var annotation2 = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.Type = "modified-type"; // Different property + }); + + var annotation3 = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.VSCodeSpecificValue = "third"; // Overrides first + }); + + var annotations = new IDebuggerPropertiesAnnotation[] { annotation1, annotation2, annotation3 }; + + var props = new TestVSCodeDebuggerProperties + { + Name = "Test", + WorkingDirectory = "/test", + Type = "original" + }; + + // Act - Apply all annotations in order + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(props); + } + + // Assert - All annotations were applied, later ones override earlier ones + Assert.Equal("modified-type", props.Type); + Assert.Equal("third", props.VSCodeSpecificValue); // Last one wins + } + + [Fact] + public void VSCodeDebuggerPropertiesBase_SerializesVSCodeOptionsAtTopLevel() + { + // This test verifies that VS Code-specific options (presentation, preLaunchTask, etc.) + // serialize directly on debugger_properties, NOT nested under a 'vscode' key. + // This is critical for the VS Code extension which spreads debugger_properties directly. + + // Arrange + var props = new TestVSCodeDebuggerProperties + { + Name = "TestProject", + WorkingDirectory = "/test/path", + Type = "coreclr", + Request = "launch", + Presentation = new PresentationOptions + { + Order = 1, + Group = "Aspire", + Hidden = false + }, + PreLaunchTask = "${defaultBuildTask}", + PostDebugTask = "cleanup", + ServerReadyAction = new ServerReadyAction + { + Action = "openExternally", + Pattern = @"Now listening on: (https?://\S+)" + } + }; + + // Act - Serialize to JSON + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + var json = JsonSerializer.Serialize(props, options); + + // Assert - VS Code options should be at top level, NOT nested under 'vscode' + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Core DAP properties + Assert.Equal("coreclr", root.GetProperty("type").GetString()); + Assert.Equal("TestProject", root.GetProperty("name").GetString()); + Assert.Equal("launch", root.GetProperty("request").GetString()); + Assert.Equal("/test/path", root.GetProperty("cwd").GetString()); + + // VS Code options should be at top level + Assert.True(root.TryGetProperty("presentation", out var presentation)); + Assert.Equal(1, presentation.GetProperty("order").GetInt32()); + Assert.Equal("Aspire", presentation.GetProperty("group").GetString()); + + Assert.True(root.TryGetProperty("preLaunchTask", out var preLaunchTask)); + Assert.Equal("${defaultBuildTask}", preLaunchTask.GetString()); + + Assert.True(root.TryGetProperty("postDebugTask", out var postDebugTask)); + Assert.Equal("cleanup", postDebugTask.GetString()); + + Assert.True(root.TryGetProperty("serverReadyAction", out var serverReadyAction)); + Assert.Equal("openExternally", serverReadyAction.GetProperty("action").GetString()); + + // There should NOT be a 'vscode' property + Assert.False(root.TryGetProperty("vscode", out _)); + } + + [Fact] + public void VSCodeDebuggerPropertiesBase_CanConfigureVSCodeOptionsViaAnnotation() + { + // Arrange + var annotation = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.Presentation = new PresentationOptions { Order = 5, Group = "Testing" }; + props.PreLaunchTask = "build"; + }); + + var props = new TestVSCodeDebuggerProperties + { + Name = "Test", + WorkingDirectory = "/test" + }; + + // Act + annotation.ConfigureDebuggerProperties(props); + + // Assert - VS Code options should be set directly on the properties + Assert.NotNull(props.Presentation); + Assert.Equal(5, props.Presentation.Order); + Assert.Equal("Testing", props.Presentation.Group); + Assert.Equal("build", props.PreLaunchTask); + } + + [Fact] + public void NonVSCodeDebuggerProperties_DoNotHaveVSCodeOptions() + { + // This test verifies that non-VS Code debugger properties + // don't have the VS Code-specific options + + // Arrange + var otherIdeProps = new TestOtherIdeDebuggerProperties + { + Name = "Test", + WorkingDirectory = "/test", + OtherIdeSpecificValue = "other-ide-specific" + }; + + // Act - Serialize to JSON + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + var json = JsonSerializer.Serialize(otherIdeProps, options); + + // Assert + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Core DAP properties should exist + Assert.True(root.TryGetProperty("type", out _)); + Assert.True(root.TryGetProperty("name", out _)); + Assert.True(root.TryGetProperty("cwd", out _)); + + // VS Code-specific properties should NOT exist + Assert.False(root.TryGetProperty("presentation", out _)); + Assert.False(root.TryGetProperty("preLaunchTask", out _)); + Assert.False(root.TryGetProperty("postDebugTask", out _)); + Assert.False(root.TryGetProperty("serverReadyAction", out _)); + Assert.False(root.TryGetProperty("vscode", out _)); + } + + #region Simulated Other IDE Support + + /// + /// Simulates what another IDE would define as debugger properties for .NET projects. + /// Extends DebugAdapterProperties directly (not VSCodeDebuggerPropertiesBase) since + /// each IDE has its own IDE-specific options. + /// + [Experimental("ASPIREEXTENSION001")] + private sealed class OtherIdeDotNetDebuggerProperties : DebugAdapterProperties + { + public override string Type { get; set; } = "dotnet"; + public override required string Name { get; set; } + public override required string WorkingDirectory { get; init; } + + // Other IDE-specific options (hypothetical) + [JsonPropertyName("externalSourcesSupport")] + public bool? ExternalSourcesSupport { get; set; } + + [JsonPropertyName("breakOnUserUnhandledExceptions")] + public bool? BreakOnUserUnhandledExceptions { get; set; } + + [JsonPropertyName("allowDynamicCode")] + public bool? AllowDynamicCode { get; set; } + + [JsonPropertyName("enableHotReload")] + public bool? EnableHotReload { get; set; } + + [JsonPropertyName("debuggerPort")] + public int? DebuggerPort { get; set; } + } + + /// + /// Simulates a launch configuration that could hold any debugger properties. + /// In the real implementation, this would be something like ProjectLaunchConfiguration + /// but generic enough to hold any debugger properties type. + /// + [Experimental("ASPIREEXTENSION001")] + private sealed class TestLaunchConfiguration where T : DebugAdapterProperties + { + [JsonPropertyName("type")] + public string Type { get; set; } = "project"; + + [JsonPropertyName("mode")] + public string Mode { get; set; } = "Debug"; + + [JsonPropertyName("debugger_properties")] + public T? DebuggerProperties { get; set; } + + [JsonPropertyName("project_path")] + public string ProjectPath { get; set; } = ""; + } + + #endregion + + [Fact] + public void SimulateOtherIdeSupport_FullEndToEndScenario() + { + // This test simulates the full scenario of how another IDE would: + // 1. Define their own debugger properties class + // 2. Register configuration via annotations + // 3. Create launch configurations with their debugger properties + // 4. Serialize everything correctly for their IDE to consume + + // ======================================== + // STEP 1: AppHost developer configures resources for multiple IDEs + // ======================================== + + // Annotations that would be added to a resource via WithVSCodeDebugging and a hypothetical WithOtherIdeDebugging + var annotations = new List + { + // VS Code configuration + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.Presentation = new PresentationOptions { Order = 1, Group = "Aspire" }; + props.PreLaunchTask = "${defaultBuildTask}"; + }), + + // Other IDE configuration (hypothetical extension method: WithOtherIdeDebugging) + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.ExternalSourcesSupport = true; + props.BreakOnUserUnhandledExceptions = true; + props.EnableHotReload = true; + }) + }; + + // ======================================== + // STEP 2: Other IDE creates its debugger properties and applies annotations + // ======================================== + + var otherIdeDebuggerProps = new OtherIdeDotNetDebuggerProperties + { + Name = "MyApi", + WorkingDirectory = "/path/to/project", + Type = "dotnet", + Request = "launch" + }; + + // Apply all annotations - only the matching one will have effect + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(otherIdeDebuggerProps); + } + + // ======================================== + // STEP 3: Create the launch configuration with the IDE's properties + // ======================================== + + var launchConfig = new TestLaunchConfiguration + { + Type = "project", + Mode = "Debug", + ProjectPath = "/path/to/project/MyApi.csproj", + DebuggerProperties = otherIdeDebuggerProps + }; + + // ======================================== + // STEP 4: Serialize and verify the JSON structure + // ======================================== + + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + var json = JsonSerializer.Serialize(launchConfig, options); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Verify top-level launch configuration properties + Assert.Equal("project", root.GetProperty("type").GetString()); + Assert.Equal("Debug", root.GetProperty("mode").GetString()); + Assert.Equal("/path/to/project/MyApi.csproj", root.GetProperty("project_path").GetString()); + + // Verify debugger_properties exists and contains the right structure + Assert.True(root.TryGetProperty("debugger_properties", out var debuggerProps)); + + // Core DAP properties + Assert.Equal("dotnet", debuggerProps.GetProperty("type").GetString()); + Assert.Equal("MyApi", debuggerProps.GetProperty("name").GetString()); + Assert.Equal("launch", debuggerProps.GetProperty("request").GetString()); + Assert.Equal("/path/to/project", debuggerProps.GetProperty("cwd").GetString()); + + // IDE-specific properties (configured via annotation) + Assert.True(debuggerProps.GetProperty("externalSourcesSupport").GetBoolean()); + Assert.True(debuggerProps.GetProperty("breakOnUserUnhandledExceptions").GetBoolean()); + Assert.True(debuggerProps.GetProperty("enableHotReload").GetBoolean()); + + // VS Code-specific properties should NOT be present (other IDE doesn't inherit from VSCodeDebuggerPropertiesBase) + Assert.False(debuggerProps.TryGetProperty("presentation", out _)); + Assert.False(debuggerProps.TryGetProperty("preLaunchTask", out _)); + Assert.False(debuggerProps.TryGetProperty("serverReadyAction", out _)); + Assert.False(debuggerProps.TryGetProperty("vscode", out _)); + } + + [Fact] + public void SimulateVSCodeIdeSupport_FullEndToEndScenario() + { + // This test shows the VS Code side of the same scenario for comparison + + // ======================================== + // STEP 1: Same annotations as other IDE test + // ======================================== + + var annotations = new List + { + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.Presentation = new PresentationOptions { Order = 1, Group = "Aspire" }; + props.PreLaunchTask = "${defaultBuildTask}"; + props.ServerReadyAction = new ServerReadyAction + { + Action = "openExternally", + Pattern = @"Now listening on: (https?://\S+)" + }; + }), + + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.ExternalSourcesSupport = true; + props.EnableHotReload = true; + }) + }; + + // ======================================== + // STEP 2: VS Code IDE creates its debugger properties and applies annotations + // ======================================== + + var vsCodeDebuggerProps = new TestVSCodeDebuggerProperties + { + Name = "MyApi", + WorkingDirectory = "/path/to/project", + Type = "coreclr", + Request = "launch" + }; + + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(vsCodeDebuggerProps); + } + + // ======================================== + // STEP 3: Create the launch configuration with VS Code properties + // ======================================== + + var launchConfig = new TestLaunchConfiguration + { + Type = "project", + Mode = "Debug", + ProjectPath = "/path/to/project/MyApi.csproj", + DebuggerProperties = vsCodeDebuggerProps + }; + + // ======================================== + // STEP 4: Serialize and verify the JSON structure + // ======================================== + + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + var json = JsonSerializer.Serialize(launchConfig, options); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Verify debugger_properties exists + Assert.True(root.TryGetProperty("debugger_properties", out var debuggerProps)); + + // Core DAP properties + Assert.Equal("coreclr", debuggerProps.GetProperty("type").GetString()); + Assert.Equal("MyApi", debuggerProps.GetProperty("name").GetString()); + + // VS Code-specific properties ARE present at top level of debugger_properties + Assert.True(debuggerProps.TryGetProperty("presentation", out var presentation)); + Assert.Equal(1, presentation.GetProperty("order").GetInt32()); + Assert.Equal("Aspire", presentation.GetProperty("group").GetString()); + + Assert.Equal("${defaultBuildTask}", debuggerProps.GetProperty("preLaunchTask").GetString()); + + Assert.True(debuggerProps.TryGetProperty("serverReadyAction", out var serverReadyAction)); + Assert.Equal("openExternally", serverReadyAction.GetProperty("action").GetString()); + + // Other IDE-specific properties should NOT be present + Assert.False(debuggerProps.TryGetProperty("externalSourcesSupport", out _)); + Assert.False(debuggerProps.TryGetProperty("enableHotReload", out _)); + + // No nested 'vscode' property + Assert.False(debuggerProps.TryGetProperty("vscode", out _)); + } + + [Fact] + public void BothIdesCanCoexist_SameResourceDifferentConfigurations() + { + // This test verifies that both IDEs can be configured on the same resource + // and each gets their own distinct configuration + + var annotations = new List + { + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.PreLaunchTask = "vscode-build"; + props.Presentation = new PresentationOptions { Hidden = false }; + }), + new ExecutableDebuggerPropertiesAnnotation(props => + { + props.EnableHotReload = true; + props.DebuggerPort = 5005; + }) + }; + + // Create both configurations + var vsCodeProps = new TestVSCodeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + var otherIdeProps = new OtherIdeDotNetDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + + // Apply all annotations to both + foreach (var annotation in annotations) + { + annotation.ConfigureDebuggerProperties(vsCodeProps); + annotation.ConfigureDebuggerProperties(otherIdeProps); + } + + // Verify VS Code got VS Code config only + Assert.Equal("vscode-build", vsCodeProps.PreLaunchTask); + Assert.NotNull(vsCodeProps.Presentation); + Assert.False(vsCodeProps.Presentation.Hidden); + + // Verify other IDE got its config only + Assert.True(otherIdeProps.EnableHotReload); + Assert.Equal(5005, otherIdeProps.DebuggerPort); + + // Serialize both and verify no cross-contamination + var options = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + + var vsCodeJson = JsonSerializer.Serialize(vsCodeProps, options); + var otherIdeJson = JsonSerializer.Serialize(otherIdeProps, options); + + using var vsCodeDoc = JsonDocument.Parse(vsCodeJson); + using var otherIdeDoc = JsonDocument.Parse(otherIdeJson); + + // VS Code JSON should have VS Code props, not other IDE props + Assert.True(vsCodeDoc.RootElement.TryGetProperty("preLaunchTask", out _)); + Assert.False(vsCodeDoc.RootElement.TryGetProperty("enableHotReload", out _)); + Assert.False(vsCodeDoc.RootElement.TryGetProperty("debuggerPort", out _)); + + // Other IDE JSON should have its props, not VS Code props + Assert.True(otherIdeDoc.RootElement.TryGetProperty("enableHotReload", out _)); + Assert.True(otherIdeDoc.RootElement.TryGetProperty("debuggerPort", out _)); + Assert.False(otherIdeDoc.RootElement.TryGetProperty("preLaunchTask", out _)); + Assert.False(otherIdeDoc.RootElement.TryGetProperty("presentation", out _)); + } + + [Fact] + public void PolymorphicSerialization_ThroughBaseTypeReference_PreservesDerivedTypeProperties() + { + // This test verifies that when a derived debugger properties type is serialized + // through a base type reference (as happens in the real launch configuration classes), + // all properties from the derived type are preserved. + // + // This is the real scenario: NodeLaunchConfiguration uses DebugAdapterProperties as + // its generic type parameter, but we assign VSCodeNodeDebuggerProperties to it. + + // Arrange - Create launch configuration similar to real NodeLaunchConfiguration + // which uses ExecutableLaunchConfigurationWithDebuggerProperties + var launchConfig = new TestLaunchConfigurationWithBaseType + { + Type = "node", + Mode = "Debug", + // DebuggerProperties is typed as DebugAdapterProperties (base type) + // but we assign a VSCodeNodeDebuggerProperties instance + DebuggerProperties = new TestVSCodeDebuggerProperties + { + Name = "Debug Node App", + WorkingDirectory = "/path/to/app", + Type = "node", + Request = "launch", + // VS Code-specific properties + PreLaunchTask = "${defaultBuildTask}", + Presentation = new PresentationOptions { Order = 1, Group = "Aspire" }, + ServerReadyAction = new ServerReadyAction { Action = "openExternally" } + } + }; + + // Act - Serialize the launch configuration + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + var json = JsonSerializer.Serialize(launchConfig, options); + + // Assert - Verify derived type properties are included in the JSON + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("debugger_properties", out var debuggerProps)); + + // Base DAP properties should be present + Assert.Equal("node", debuggerProps.GetProperty("type").GetString()); + Assert.Equal("Debug Node App", debuggerProps.GetProperty("name").GetString()); + Assert.Equal("launch", debuggerProps.GetProperty("request").GetString()); + + // VS Code-specific properties should also be present (this is the key assertion) + Assert.True(debuggerProps.TryGetProperty("preLaunchTask", out var preLaunchTask), + "VS Code-specific property 'preLaunchTask' should be serialized through base type reference"); + Assert.Equal("${defaultBuildTask}", preLaunchTask.GetString()); + + Assert.True(debuggerProps.TryGetProperty("presentation", out var presentation), + "VS Code-specific property 'presentation' should be serialized through base type reference"); + Assert.Equal(1, presentation.GetProperty("order").GetInt32()); + + Assert.True(debuggerProps.TryGetProperty("serverReadyAction", out var serverReady), + "VS Code-specific property 'serverReadyAction' should be serialized through base type reference"); + Assert.Equal("openExternally", serverReady.GetProperty("action").GetString()); + } + + /// + /// Simulates the real launch configuration pattern where DebuggerProperties + /// is typed as the base type (DebugAdapterProperties) but can hold derived types. + /// + [Experimental("ASPIREEXTENSION001")] + private sealed class TestLaunchConfigurationWithBaseType + { + [JsonPropertyName("type")] + public string Type { get; set; } = "project"; + + [JsonPropertyName("mode")] + public string Mode { get; set; } = "Debug"; + + /// + /// This is typed as the base type, mimicking how NodeLaunchConfiguration, + /// ProjectLaunchConfiguration, etc. declare their DebuggerProperties property. + /// + [JsonPropertyName("debugger_properties")] + public DebugAdapterProperties? DebuggerProperties { get; set; } + } + + [Fact] + public void MultipleAnnotationsOfSameIdeType_AllApplyInOrder() + { + var annotation1 = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.VSCodeSpecificValue = "first"; + }); + + var annotation2 = new ExecutableDebuggerPropertiesAnnotation(props => + { + props.VSCodeSpecificValue = props.VSCodeSpecificValue + "+second"; + }); + + var props = new TestVSCodeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + + annotation1.ConfigureDebuggerProperties(props); + annotation2.ConfigureDebuggerProperties(props); + + Assert.Equal("first+second", props.VSCodeSpecificValue); + } + + [Fact] + public void Annotation_WithIdeTypeFilter_OnlyAppliesWhenIdeMatches() + { + var annotation = new ExecutableDebuggerPropertiesAnnotation( + props => { props.WasConfigured = true; }, + ideType: "vscode"); + + Assert.Equal("vscode", annotation.IdeType); + + var props = new TestVSCodeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + annotation.ConfigureDebuggerProperties(props); + + Assert.True(props.WasConfigured); + } + + [Fact] + public void Annotation_WithNullIdeType_AppliesToAllIdes() + { + var annotation = new ExecutableDebuggerPropertiesAnnotation( + props => { props.WasConfigured = true; }, + ideType: null); + + Assert.Null(annotation.IdeType); + + var props = new TestVSCodeDebuggerProperties { Name = "Test", WorkingDirectory = "/test" }; + annotation.ConfigureDebuggerProperties(props); + + Assert.True(props.WasConfigured); + } + + [Fact] + public void ExecutableLaunchConfiguration_Serialization_NullPropertiesOmitted() + { + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + var props = new TestVSCodeDebuggerProperties + { + Name = "Test Debug", + WorkingDirectory = "/app", + Type = "coreclr", + }; + + var json = JsonSerializer.Serialize(props, options); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("Test Debug", root.GetProperty("name").GetString()); + Assert.Equal("/app", root.GetProperty("cwd").GetString()); + Assert.Equal("coreclr", root.GetProperty("type").GetString()); + + // Null VS Code-specific properties should be omitted + Assert.False(root.TryGetProperty("preLaunchTask", out _)); + Assert.False(root.TryGetProperty("presentation", out _)); + Assert.False(root.TryGetProperty("serverReadyAction", out _)); + } + + [Fact] + public void ExecutableLaunchConfiguration_Serialization_RequiredPropertiesPresent() + { + var config = new TestLaunchConfigurationWithBaseType + { + Type = "project", + Mode = "Debug", + DebuggerProperties = new TestVSCodeDebuggerProperties + { + Name = "Debug Project", + WorkingDirectory = "/workspace/app", + Type = "coreclr" + } + }; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + var json = JsonSerializer.Serialize(config, options); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("project", root.GetProperty("type").GetString()); + Assert.Equal("Debug", root.GetProperty("mode").GetString()); + + Assert.True(root.TryGetProperty("debugger_properties", out var debuggerProps)); + Assert.Equal("coreclr", debuggerProps.GetProperty("type").GetString()); + Assert.Equal("Debug Project", debuggerProps.GetProperty("name").GetString()); + Assert.Equal("/workspace/app", debuggerProps.GetProperty("cwd").GetString()); + } +} diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 174f0b8d365..3ddf90103f2 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -6,6 +6,7 @@ using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.Tests; @@ -75,17 +76,21 @@ public void WithWorkingDirectoryAllowsEmptyString() public void WithDebugSupportAddsAnnotationInRunMode() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var launchConfig = new ExecutableLaunchConfiguration("python"); + var launchConfig = new CustomExecutableLaunchConfiguration("python"); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") .WithDebugSupport(_ => launchConfig, "ms-python.python"); var annotation = executable.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); var exe = new Executable(new ExecutableSpec()); - annotation.LaunchConfigurationAnnotator(exe, "NoDebug"); + annotation.LaunchConfigurationAnnotator(exe, new LaunchConfigurationProducerOptions + { + Mode = "NoDebug", + DebugConsoleLogger = NullLogger.Instance + }); Assert.Equal("ms-python.python", annotation.LaunchConfigurationType); - Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var annotations)); + Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var annotations)); Assert.Equal(launchConfig.Mode, annotations.Single().Mode); Assert.Equal(launchConfig.Type, annotations.Single().Type); } @@ -95,9 +100,11 @@ public void WithDebugSupportDoesNotAddAnnotationInPublishMode() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("python"), "ms-python.python"); + .WithDebugSupport(_ => new CustomExecutableLaunchConfiguration("python"), "ms-python.python"); var annotation = executable.Resource.Annotations.OfType().SingleOrDefault(); Assert.Null(annotation); } + + private sealed class CustomExecutableLaunchConfiguration(string type) : ExecutableLaunchConfiguration(type); }