diff --git a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs index 5a723f60c2a..5c1153ddd55 100644 --- a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs +++ b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs @@ -304,18 +304,21 @@ public void WriteSummary() /// /// Formats a single key-value pair for the pipeline summary display. + /// Values may contain markdown links which are converted to clickable links when supported. /// private string FormatPipelineSummaryKvp(string key, string value) { if (_enableColor) { var escapedKey = key.EscapeMarkup(); - var escapedValue = value.EscapeMarkup(); - return $" [blue]{escapedKey}[/]: {escapedValue}"; + var convertedValue = MarkdownToSpectreConverter.ConvertToSpectre(value); + convertedValue = HighlightMessage(convertedValue); + return $" [blue]{escapedKey}[/]: {convertedValue}"; } else { - return $" {key}: {value}"; + var plainValue = MarkdownToSpectreConverter.ConvertLinksToPlainText(value); + return $" {key}: {plainValue}"; } } diff --git a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs index bc8d3d5c6fd..000fdfcc437 100644 --- a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs +++ b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs @@ -61,6 +61,16 @@ public static string ConvertToSpectre(string markdown) return result; } + /// + /// Converts markdown links to plain text. + /// + /// The markdown text to convert. + /// The text with markdown links converted to the plain text format text (url). + public static string ConvertLinksToPlainText(string markdown) + { + return LinkRegex().Replace(markdown, "$1 ($2)"); + } + private static string ConvertHeaders(string text) { // Convert ###### Header 6 (most specific first) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 19a62a9e3e5..7325e690d4b 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -133,18 +133,19 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet /// The Azure provisioning context. private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningContext provisioningContext) { - // Safely access the nested properties with null checks for reference types - // AzureLocation is a struct so it cannot be null - var resourceGroupName = provisioningContext.ResourceGroup?.Name ?? "unknown"; - var subscriptionId = provisioningContext.Subscription?.Id.Name ?? "unknown"; + var resourceGroupName = provisioningContext.ResourceGroup.Name; + var subscriptionId = provisioningContext.Subscription.Id.Name; var location = provisioningContext.Location.Name; -#pragma warning disable ASPIREPIPELINES001 // PipelineSummary is experimental + var tenantId = provisioningContext.Tenant.TenantId; + var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#"; + var portalUrl = $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + var resourceGroupValue = $"[{resourceGroupName}]({portalUrl})"; + ctx.Summary.Add("☁️ Target", "Azure"); - ctx.Summary.Add("📦 Resource Group", resourceGroupName); + ctx.Summary.Add("📦 Resource Group", resourceGroupValue); ctx.Summary.Add("🔑 Subscription", subscriptionId); ctx.Summary.Add("🌐 Location", location); -#pragma warning restore ASPIREPIPELINES001 } private Task PublishAsync(PipelineStepContext context) diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs new file mode 100644 index 00000000000..9410d688b19 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -0,0 +1,141 @@ +// 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; +using Aspire.Cli.Utils; +using Spectre.Console; + +namespace Aspire.Cli.Tests.Utils; + +public class ConsoleActivityLoggerTests +{ + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_RendersClickableLink() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("📦 Resource Group", "VNetTest5 [link](https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview)"), + new("🔑 Subscription", "sub-id"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // Verify the markdown link was converted to a Spectre link + Assert.Contains("VNetTest5", result); + + const string expectedUrl = + @"https://portal\.azure\.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + string hyperlinkPattern = + $@"\u001b\]8;[^;]*;{expectedUrl}\u001b\\.*link.*\u001b\]8;;\u001b\\"; + Assert.Matches(hyperlinkPattern, result); + } + + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainTextWithUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: false); + + var portalUrl = "https://portal.azure.com/"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is disabled, markdown links should be converted to plain text: text (url) + Assert.Contains($"VNetTest5 link ({portalUrl})", result); + } + + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_ColorWithoutInteractive_RendersPlainUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + // Non-interactive host but color enabled (e.g., CI environments with ANSI support) + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var portalUrl = "https://portal.azure.com/"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is enabled but interactive output is not supported, + // HighlightMessage converts Spectre link markup to plain URLs + Assert.Contains("VNetTest5", result); + Assert.Contains(portalUrl, result); + + // Should NOT contain the OSC 8 hyperlink escape sequence since we're non-interactive + Assert.DoesNotContain("\u001b]8;", result); + } + + [Fact] + public void WriteSummary_WithPlainTextPipelineSummary_RendersCorrectly() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + Assert.Contains("Azure", result); + Assert.Contains("eastus", result); + } +}