From ea377a7eb1b4746c6290ced76a5a94c79f92a7e0 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 2 Mar 2026 23:58:28 +0800 Subject: [PATCH 01/18] Many telemetry commands improvements (#14838) * Many telemetry commands improvements * Clean up * Test cleanup * Test cleanup --- src/Aspire.Cli/Aspire.Cli.csproj | 2 + src/Aspire.Cli/Commands/DescribeCommand.cs | 4 +- src/Aspire.Cli/Commands/LogsCommand.cs | 4 +- .../Commands/TelemetryCommandHelpers.cs | 52 ++++- .../Commands/TelemetryLogsCommand.cs | 45 ++-- .../Commands/TelemetrySpansCommand.cs | 54 +++-- .../Commands/TelemetryTracesCommand.cs | 77 ++++--- src/Aspire.Cli/Program.cs | 1 + .../TelemetryCommandStrings.Designer.cs | 8 +- .../Resources/TelemetryCommandStrings.resx | 8 +- .../xlf/TelemetryCommandStrings.cs.xlf | 12 +- .../xlf/TelemetryCommandStrings.de.xlf | 12 +- .../xlf/TelemetryCommandStrings.es.xlf | 12 +- .../xlf/TelemetryCommandStrings.fr.xlf | 12 +- .../xlf/TelemetryCommandStrings.it.xlf | 12 +- .../xlf/TelemetryCommandStrings.ja.xlf | 12 +- .../xlf/TelemetryCommandStrings.ko.xlf | 12 +- .../xlf/TelemetryCommandStrings.pl.xlf | 12 +- .../xlf/TelemetryCommandStrings.pt-BR.xlf | 12 +- .../xlf/TelemetryCommandStrings.ru.xlf | 12 +- .../xlf/TelemetryCommandStrings.tr.xlf | 12 +- .../xlf/TelemetryCommandStrings.zh-Hans.xlf | 12 +- .../xlf/TelemetryCommandStrings.zh-Hant.xlf | 12 +- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 2 + .../Storage/TelemetryRepository.Watchers.cs | 23 +- .../Utils => Shared}/FormatHelpers.cs | 16 +- src/Shared/Otlp/OtlpHelpers.cs | 14 -- .../Otlp/Serialization/OtlpResourceJson.cs | 21 ++ .../TimeProviderExtensions.cs | 8 +- .../Commands/TelemetryCommandTests.cs | 175 +++++++-------- .../Commands/TelemetryLogsCommandTests.cs | 149 +++++++++++++ .../Commands/TelemetrySpansCommandTests.cs | 151 +++++++++++++ .../Commands/TelemetryTestHelper.cs | 129 ++++++++++++ .../Commands/TelemetryTracesCommandTests.cs | 199 ++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 1 + .../TelemetryRepositoryTests.cs | 90 ++++++++ 36 files changed, 1099 insertions(+), 290 deletions(-) rename src/{Aspire.Dashboard/Utils => Shared}/FormatHelpers.cs (88%) rename src/{Aspire.Dashboard/Extensions => Shared}/TimeProviderExtensions.cs (73%) create mode 100644 tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/TelemetryTestHelper.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 6944c894d90..e6d7574c02c 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -87,6 +87,8 @@ + + diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index ee63be937a4..a0f5469655d 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -71,7 +71,7 @@ internal sealed class DescribeCommand : BaseCommand private readonly IInteractionService _interactionService; private readonly AppHostConnectionResolver _connectionResolver; - private readonly ResourceColorMap _resourceColorMap = new(); + private readonly ResourceColorMap _resourceColorMap; private static readonly Argument s_resourceArgument = new("resource") { @@ -95,11 +95,13 @@ public DescribeCommand( ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry, + ResourceColorMap resourceColorMap, ILogger logger) : base("describe", DescribeCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { Aliases.Add("resources"); _interactionService = interactionService; + _resourceColorMap = resourceColorMap; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); Arguments.Add(s_resourceArgument); diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 4d46b8e94c0..9f741c8c65e 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -101,7 +101,7 @@ internal sealed class LogsCommand : BaseCommand Description = LogsCommandStrings.TimestampsOptionDescription }; - private readonly ResourceColorMap _resourceColorMap = new(); + private readonly ResourceColorMap _resourceColorMap; public LogsCommand( IInteractionService interactionService, @@ -110,9 +110,11 @@ public LogsCommand( ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry, + ResourceColorMap resourceColorMap, ILogger logger) : base("logs", LogsCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { + _resourceColorMap = resourceColorMap; _interactionService = interactionService; _logger = logger; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 7f782b9e0be..03ff68822be 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -10,6 +10,7 @@ using Aspire.Cli.Resources; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; using Aspire.Shared; using Spectre.Console; @@ -215,10 +216,11 @@ public static async Task GetAllResourcesAsync(HttpClient cli /// /// Displays a "no data found" message with consistent styling. /// + /// The interaction service for output. /// The type of data (e.g., "logs", "spans", "traces"). - public static void DisplayNoData(string dataType) + public static void DisplayNoData(IInteractionService interactionService, string dataType) { - AnsiConsole.MarkupLine($"[yellow]No {dataType} found[/]"); + interactionService.DisplayMarkupLine($"[yellow]No {dataType} found[/]"); } /// @@ -249,6 +251,24 @@ public static string FormatDuration(TimeSpan duration) return DurationFormatter.FormatDuration(duration, CultureInfo.InvariantCulture); } + /// + /// Gets abbreviated severity text for an OTLP severity number. + /// OTLP severity numbers: 1-4=TRACE, 5-8=DEBUG, 9-12=INFO, 13-16=WARN, 17-20=ERROR, 21-24=FATAL + /// + public static string GetSeverityText(int? severityNumber) + { + return severityNumber switch + { + >= 21 => "CRIT", + >= 17 => "FAIL", + >= 13 => "WARN", + >= 9 => "INFO", + >= 5 => "DBUG", + >= 1 => "TRCE", + _ => "-" + }; + } + /// /// Gets Spectre Console color for a log severity number. /// OTLP severity numbers: 1-4=TRACE, 5-8=DEBUG, 9-12=INFO, 13-16=WARN, 17-20=ERROR, 21-24=FATAL @@ -287,4 +307,32 @@ public static async IAsyncEnumerable ReadLinesAsync( } } } + + /// + /// Converts an array of to a list of for use with . + /// + public static IReadOnlyList ToOtlpResources(ResourceInfoJson[] resources) + { + var result = new IOtlpResource[resources.Length]; + for (var i = 0; i < resources.Length; i++) + { + result[i] = new SimpleOtlpResource(resources[i].Name, resources[i].InstanceId); + } + return result; + } + + /// + /// Resolves the display name for an OTLP resource using , + /// appending a shortened instance ID when there are replicas with the same base name. + /// + public static string ResolveResourceName(OtlpResourceJson? resource, IReadOnlyList allResources) + { + if (resource is null) + { + return "unknown"; + } + + var otlpResource = new SimpleOtlpResource(resource.GetServiceName(), resource.GetServiceInstanceId()); + return OtlpHelpers.GetResourceName(otlpResource, allResources); + } } diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index dc0d0492842..d488952a91e 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -28,6 +28,8 @@ internal sealed class TelemetryLogsCommand : BaseCommand private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; + private readonly ResourceColorMap _resourceColorMap; + private readonly TimeProvider _timeProvider; // Shared options from TelemetryCommandHelpers private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument(); @@ -50,11 +52,15 @@ public TelemetryLogsCommand( CliExecutionContext executionContext, AspireCliTelemetry telemetry, IHttpClientFactory httpClientFactory, + ResourceColorMap resourceColorMap, + TimeProvider timeProvider, ILogger logger) : base("logs", TelemetryCommandStrings.LogsDescription, features, updateNotifier, executionContext, interactionService, telemetry) { _interactionService = interactionService; _httpClientFactory = httpClientFactory; + _resourceColorMap = resourceColorMap; + _timeProvider = timeProvider; _logger = logger; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); @@ -120,6 +126,8 @@ private async Task FetchLogsAsync( return ExitCodeConstants.InvalidCommand; } + var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); + // Build query string with multiple resource parameters var additionalParams = new List<(string key, string? value)> { @@ -141,11 +149,11 @@ private async Task FetchLogsAsync( { if (follow) { - return await StreamLogsAsync(client, url, format, cancellationToken); + return await StreamLogsAsync(client, url, format, allOtlpResources, cancellationToken); } else { - return await GetLogsSnapshotAsync(client, url, format, cancellationToken); + return await GetLogsSnapshotAsync(client, url, format, allOtlpResources, cancellationToken); } } catch (HttpRequestException ex) @@ -156,7 +164,7 @@ private async Task FetchLogsAsync( } } - private async Task GetLogsSnapshotAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken) + private async Task GetLogsSnapshotAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList allResources, CancellationToken cancellationToken) { var response = await client.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); @@ -176,13 +184,13 @@ private async Task GetLogsSnapshotAsync(HttpClient client, string url, Outp } else { - DisplayLogsSnapshot(json); + DisplayLogsSnapshot(json, allResources); } return ExitCodeConstants.Success; } - private async Task StreamLogsAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken) + private async Task StreamLogsAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList allResources, CancellationToken cancellationToken) { using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); @@ -205,38 +213,38 @@ private async Task StreamLogsAsync(HttpClient client, string url, OutputFor } else { - DisplayLogsStreamLine(line); + DisplayLogsStreamLine(line, allResources); } } return ExitCodeConstants.Success; } - private static void DisplayLogsSnapshot(string json) + private void DisplayLogsSnapshot(string json, IReadOnlyList allResources) { var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); var resourceLogs = response?.Data?.ResourceLogs; if (resourceLogs is null or { Length: 0 }) { - TelemetryCommandHelpers.DisplayNoData("logs"); + TelemetryCommandHelpers.DisplayNoData(_interactionService, "logs"); return; } - DisplayResourceLogs(resourceLogs); + DisplayResourceLogs(resourceLogs, allResources); } - private static void DisplayLogsStreamLine(string json) + private void DisplayLogsStreamLine(string json, IReadOnlyList allResources) { var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportLogsServiceRequestJson); - DisplayResourceLogs(request?.ResourceLogs ?? []); + DisplayResourceLogs(request?.ResourceLogs ?? [], allResources); } - private static void DisplayResourceLogs(IEnumerable resourceLogs) + private void DisplayResourceLogs(IEnumerable resourceLogs, IReadOnlyList allResources) { foreach (var resourceLog in resourceLogs) { - var resourceName = resourceLog.Resource?.GetServiceName() ?? "unknown"; + var resourceName = TelemetryCommandHelpers.ResolveResourceName(resourceLog.Resource, allResources); foreach (var scopeLog in resourceLog.ScopeLogs ?? []) { @@ -250,16 +258,19 @@ private static void DisplayResourceLogs(IEnumerable resour // Using simple text lines instead of Spectre.Console Table for streaming support. // Tables require knowing all data upfront, but streaming mode displays logs as they arrive. - private static void DisplayLogEntry(string resourceName, OtlpLogRecordJson log) + private void DisplayLogEntry(string resourceName, OtlpLogRecordJson log) { - var timestamp = OtlpHelpers.FormatNanoTimestamp(log.TimeUnixNano); - var severity = log.SeverityText ?? ""; + var timestamp = log.TimeUnixNano.HasValue + ? FormatHelpers.FormatConsoleTime(_timeProvider, OtlpHelpers.UnixNanoSecondsToDateTime(log.TimeUnixNano.Value)) + : ""; + var severity = TelemetryCommandHelpers.GetSeverityText(log.SeverityNumber); var body = log.Body?.StringValue ?? ""; // Use severity number for color mapping (more reliable than text) var severityColor = TelemetryCommandHelpers.GetSeverityColor(log.SeverityNumber); + var resourceColor = _resourceColorMap.GetColor(resourceName); var escapedBody = body.EscapeMarkup(); - AnsiConsole.MarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-5}[/] [cyan]{resourceName.EscapeMarkup()}[/] {escapedBody}"); + _interactionService.DisplayMarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-4}[/] [{resourceColor}]{resourceName.EscapeMarkup()}[/] {escapedBody}"); } } diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index e9ae5ca8225..6130da4c76f 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -28,6 +28,8 @@ internal sealed class TelemetrySpansCommand : BaseCommand private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; + private readonly ResourceColorMap _resourceColorMap; + private readonly TimeProvider _timeProvider; // Shared options from TelemetryCommandHelpers private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument(); @@ -46,11 +48,15 @@ public TelemetrySpansCommand( CliExecutionContext executionContext, AspireCliTelemetry telemetry, IHttpClientFactory httpClientFactory, + ResourceColorMap resourceColorMap, + TimeProvider timeProvider, ILogger logger) : base("spans", TelemetryCommandStrings.SpansDescription, features, updateNotifier, executionContext, interactionService, telemetry) { _interactionService = interactionService; _httpClientFactory = httpClientFactory; + _resourceColorMap = resourceColorMap; + _timeProvider = timeProvider; _logger = logger; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); @@ -116,6 +122,8 @@ private async Task FetchSpansAsync( return ExitCodeConstants.InvalidCommand; } + var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); + // Build query string with multiple resource parameters var additionalParams = new List<(string key, string? value)> { @@ -142,11 +150,11 @@ private async Task FetchSpansAsync( { if (follow) { - return await StreamSpansAsync(client, url, format, cancellationToken); + return await StreamSpansAsync(client, url, format, allOtlpResources, cancellationToken); } else { - return await GetSpansSnapshotAsync(client, url, format, cancellationToken); + return await GetSpansSnapshotAsync(client, url, format, allOtlpResources, cancellationToken); } } catch (HttpRequestException ex) @@ -157,7 +165,7 @@ private async Task FetchSpansAsync( } } - private async Task GetSpansSnapshotAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken) + private async Task GetSpansSnapshotAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList allResources, CancellationToken cancellationToken) { var response = await client.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); @@ -177,14 +185,13 @@ private async Task GetSpansSnapshotAsync(HttpClient client, string url, Out } else { - // Parse OTLP JSON and display in table format - DisplaySpansSnapshot(json); + DisplaySpansSnapshot(json, allResources); } return ExitCodeConstants.Success; } - private async Task StreamSpansAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken) + private async Task StreamSpansAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList allResources, CancellationToken cancellationToken) { using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); @@ -207,52 +214,59 @@ private async Task StreamSpansAsync(HttpClient client, string url, OutputFo } else { - DisplaySpansStreamLine(line); + DisplaySpansStreamLine(line, allResources); } } return ExitCodeConstants.Success; } - private static void DisplaySpansSnapshot(string json) + private void DisplaySpansSnapshot(string json, IReadOnlyList allResources) { var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); var resourceSpans = response?.Data?.ResourceSpans; if (resourceSpans is null or { Length: 0 }) { - TelemetryCommandHelpers.DisplayNoData("spans"); + TelemetryCommandHelpers.DisplayNoData(_interactionService, "spans"); return; } - DisplayResourceSpans(resourceSpans); + DisplayResourceSpans(resourceSpans, allResources); } - private static void DisplaySpansStreamLine(string json) + private void DisplaySpansStreamLine(string json, IReadOnlyList allResources) { var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportTraceServiceRequestJson); - DisplayResourceSpans(request?.ResourceSpans ?? []); + DisplayResourceSpans(request?.ResourceSpans ?? [], allResources); } - private static void DisplayResourceSpans(IEnumerable resourceSpans) + private void DisplayResourceSpans(IEnumerable resourceSpans, IReadOnlyList allResources) { + var allSpans = new List<(string ResourceName, OtlpSpanJson Span)>(); + foreach (var resourceSpan in resourceSpans) { - var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown"; + var resourceName = TelemetryCommandHelpers.ResolveResourceName(resourceSpan.Resource, allResources); foreach (var scopeSpan in resourceSpan.ScopeSpans ?? []) { foreach (var span in scopeSpan.Spans ?? []) { - DisplaySpanEntry(resourceName, span); + allSpans.Add((resourceName, span)); } } } + + foreach (var (resourceName, span) in allSpans.OrderBy(s => s.Span.StartTimeUnixNano ?? 0)) + { + DisplaySpanEntry(resourceName, span); + } } // Using simple text lines instead of Spectre.Console Table for streaming support. // Tables require knowing all data upfront, but streaming mode displays spans as they arrive. - private static void DisplaySpanEntry(string resourceName, OtlpSpanJson span) + private void DisplaySpanEntry(string resourceName, OtlpSpanJson span) { var name = span.Name ?? ""; var spanId = span.SpanId ?? ""; @@ -260,12 +274,16 @@ private static void DisplaySpanEntry(string resourceName, OtlpSpanJson span) var hasError = span.Status?.Code == 2; // ERROR status var statusColor = hasError ? Color.Red : Color.Green; - var statusText = hasError ? "ERR" : "OK"; + var statusText = hasError ? "ERR" : "OK "; + var timestamp = span.StartTimeUnixNano.HasValue + ? FormatHelpers.FormatConsoleTime(_timeProvider, OtlpHelpers.UnixNanoSecondsToDateTime(span.StartTimeUnixNano.Value)) + : ""; var shortSpanId = OtlpHelpers.ToShortenedId(spanId); var durationStr = TelemetryCommandHelpers.FormatDuration(duration); + var resourceColor = _resourceColorMap.GetColor(resourceName); var escapedName = name.EscapeMarkup(); - AnsiConsole.MarkupLine($"[grey]{shortSpanId}[/] [cyan]{resourceName.EscapeMarkup(),-15}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] {escapedName}"); + _interactionService.DisplayMarkupLine($"[grey]{timestamp}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] [{resourceColor}]{resourceName.EscapeMarkup()}[/]: {escapedName} [grey]{shortSpanId}[/]"); } } diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index ac44bbba18c..c1808073429 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -27,6 +27,8 @@ internal sealed class TelemetryTracesCommand : BaseCommand private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; + private readonly ResourceColorMap _resourceColorMap; + private readonly TimeProvider _timeProvider; // Shared options from TelemetryCommandHelpers private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument(); @@ -44,11 +46,15 @@ public TelemetryTracesCommand( CliExecutionContext executionContext, AspireCliTelemetry telemetry, IHttpClientFactory httpClientFactory, + ResourceColorMap resourceColorMap, + TimeProvider timeProvider, ILogger logger) : base("traces", TelemetryCommandStrings.TracesDescription, features, updateNotifier, executionContext, interactionService, telemetry) { _interactionService = interactionService; _httpClientFactory = httpClientFactory; + _resourceColorMap = resourceColorMap; + _timeProvider = timeProvider; _logger = logger; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); @@ -105,6 +111,10 @@ private async Task FetchSingleTraceAsync( { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); + // Fetch resources for name resolution + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); + var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); + var url = DashboardUrls.TelemetryTraceDetailApiUrl(baseUrl, traceId); _logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url); @@ -136,7 +146,7 @@ private async Task FetchSingleTraceAsync( } else { - DisplayTraceDetails(json, traceId); + DisplayTraceDetails(json, traceId, allOtlpResources); } return ExitCodeConstants.Success; @@ -170,6 +180,8 @@ private async Task FetchTracesAsync( return ExitCodeConstants.InvalidCommand; } + var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); + // Build query string with multiple resource parameters var additionalParams = new List<(string key, string? value)>(); if (hasError.HasValue) @@ -205,7 +217,7 @@ private async Task FetchTracesAsync( } else { - DisplayTracesTable(json, _interactionService); + DisplayTracesTable(json, allOtlpResources); } return ExitCodeConstants.Success; @@ -218,30 +230,30 @@ private async Task FetchTracesAsync( } } - private static void DisplayTracesTable(string json, IInteractionService interactionService) + private void DisplayTracesTable(string json, IReadOnlyList allResources) { var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); var resourceSpans = response?.Data?.ResourceSpans; if (resourceSpans is null or { Length: 0 }) { - TelemetryCommandHelpers.DisplayNoData("traces"); + TelemetryCommandHelpers.DisplayNoData(_interactionService, "traces"); return; } var table = new Table(); - table.AddBoldColumn(TelemetryCommandStrings.HeaderTraceId); - table.AddBoldColumn(TelemetryCommandStrings.HeaderResource); - table.AddBoldColumn(TelemetryCommandStrings.HeaderDuration); + table.AddBoldColumn(TelemetryCommandStrings.HeaderTimestamp); + table.AddBoldColumn(TelemetryCommandStrings.HeaderName); table.AddBoldColumn(TelemetryCommandStrings.HeaderSpans); + table.AddBoldColumn(TelemetryCommandStrings.HeaderDuration); table.AddBoldColumn(TelemetryCommandStrings.HeaderStatus); // Group by traceId to show trace summary - var traceInfos = new Dictionary(); + var traceInfos = new Dictionary(); foreach (var resourceSpan in resourceSpans) { - var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown"; + var resourceName = TelemetryCommandHelpers.ResolveResourceName(resourceSpan.Resource, allResources); foreach (var scopeSpan in resourceSpan.ScopeSpans ?? []) { @@ -260,28 +272,38 @@ private static void DisplayTracesTable(string json, IInteractionService interact if (traceInfos.TryGetValue(traceIdValue, out var info)) { var maxDuration = info.Duration > duration ? info.Duration : duration; - traceInfos[traceIdValue] = (info.Resource, maxDuration, info.SpanCount + 1, info.HasError || hasError); + // Track earliest start time across all spans in the trace + var earliestStart = info.StartTimeNano.HasValue && span.StartTimeUnixNano.HasValue + ? (info.StartTimeNano.Value < span.StartTimeUnixNano.Value ? info.StartTimeNano : span.StartTimeUnixNano) + : info.StartTimeNano ?? span.StartTimeUnixNano; + traceInfos[traceIdValue] = (info.Resource, info.FirstSpanName, info.TraceId, earliestStart, maxDuration, info.SpanCount + 1, info.HasError || hasError); } else { - traceInfos[traceIdValue] = (resourceName, duration, 1, hasError); + traceInfos[traceIdValue] = (resourceName, span.Name ?? "", traceIdValue, span.StartTimeUnixNano, duration, 1, hasError); } } } } - foreach (var (traceIdKey, info) in traceInfos.OrderByDescending(x => x.Value.Duration)) + foreach (var (_, info) in traceInfos.OrderBy(x => x.Value.StartTimeNano ?? 0)) { var statusText = info.HasError ? "[red]ERR[/]" : "[green]OK[/]"; var durationStr = TelemetryCommandHelpers.FormatDuration(info.Duration); - table.AddRow(traceIdKey, info.Resource, durationStr, info.SpanCount.ToString(CultureInfo.InvariantCulture), statusText); + var resourceColor = _resourceColorMap.GetColor(info.Resource); + var timestamp = info.StartTimeNano.HasValue + ? FormatHelpers.FormatConsoleTime(_timeProvider, OtlpHelpers.UnixNanoSecondsToDateTime(info.StartTimeNano.Value)) + : ""; + var shortTraceId = OtlpHelpers.ToShortenedId(info.TraceId); + var nameMarkup = $"[{resourceColor}]{info.Resource.EscapeMarkup()}[/]: {info.FirstSpanName.EscapeMarkup()} [grey]{shortTraceId}[/]"; + table.AddRow(timestamp, nameMarkup, info.SpanCount.ToString(CultureInfo.InvariantCulture), durationStr, statusText); } - interactionService.DisplayRenderable(table); - interactionService.DisplayMarkupLine($"[grey]Showing {traceInfos.Count} of {response?.TotalCount ?? traceInfos.Count} traces[/]"); + _interactionService.DisplayRenderable(table); + _interactionService.DisplayMarkupLine($"[grey]Showing {traceInfos.Count} of {response?.TotalCount ?? traceInfos.Count} traces[/]"); } - private static void DisplayTraceDetails(string json, string traceId) + private void DisplayTraceDetails(string json, string traceId, IReadOnlyList allResources) { var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); var resourceSpans = response?.Data?.ResourceSpans; @@ -291,7 +313,7 @@ private static void DisplayTraceDetails(string json, string traceId) foreach (var resourceSpan in resourceSpans ?? []) { - var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown"; + var resourceName = TelemetryCommandHelpers.ResolveResourceName(resourceSpan.Resource, allResources); foreach (var scopeSpan in resourceSpan.ScopeSpans ?? []) { @@ -311,8 +333,8 @@ private static void DisplayTraceDetails(string json, string traceId) if (spans.Count == 0) { - AnsiConsole.MarkupLine($"[bold]Trace: {traceId}[/]"); - AnsiConsole.MarkupLine("[dim]No spans found[/]"); + _interactionService.DisplayMarkupLine($"[bold]Trace: {traceId}[/]"); + _interactionService.DisplayMarkupLine("[dim]No spans found[/]"); return; } @@ -321,15 +343,15 @@ private static void DisplayTraceDetails(string json, string traceId) var totalDuration = rootSpans.Count > 0 ? rootSpans.Max(s => s.Duration) : spans.Max(s => s.Duration); // Header - AnsiConsole.MarkupLine($"[bold]Trace:[/] {traceId}"); - AnsiConsole.MarkupLine($"[bold]Duration:[/] {TelemetryCommandHelpers.FormatDuration(totalDuration)} [bold]Spans:[/] {spans.Count}"); - AnsiConsole.WriteLine(); + _interactionService.DisplayMarkupLine($"[bold]Trace:[/] {traceId}"); + _interactionService.DisplayMarkupLine($"[bold]Duration:[/] {TelemetryCommandHelpers.FormatDuration(totalDuration)} [bold]Spans:[/] {spans.Count}"); + _interactionService.DisplayEmptyLine(); // Build tree and display DisplaySpanTree(spans); } - private static void DisplaySpanTree(List spans) + private void DisplaySpanTree(List spans) { // Build a lookup of children by parent ID var childrenByParent = spans @@ -353,7 +375,7 @@ private static void DisplaySpanTree(List spans) } } - private static void DisplaySpanNode( + private void DisplaySpanNode( SpanInfo span, Dictionary> childrenByParent, string indent, @@ -365,9 +387,10 @@ private static void DisplaySpanNode( { if (lastResource != null) { - AnsiConsole.WriteLine(); // Blank line between resources + _interactionService.DisplayEmptyLine(); // Blank line between resources } - AnsiConsole.MarkupLine($"{indent}[bold blue]{span.ResourceName.EscapeMarkup()}[/]"); + var resourceColor = _resourceColorMap.GetColor(span.ResourceName); + _interactionService.DisplayMarkupLine($"{indent}[bold {resourceColor}]{span.ResourceName.EscapeMarkup()}[/]"); lastResource = span.ResourceName; } @@ -388,7 +411,7 @@ private static void DisplaySpanNode( ? escapedName[..(maxNameLength - 3)] + "..." : escapedName; - AnsiConsole.MarkupLine($"{indent}{connector} [dim]{shortenedSpanId}[/] {displayName} [{statusColor}]{statusText}[/] [dim]{durationStr}[/]"); + _interactionService.DisplayMarkupLine($"{indent}{connector} [dim]{shortenedSpanId}[/] {displayName} [{statusColor}]{statusText}[/] [dim]{durationStr}[/]"); // Render children if (childrenByParent.TryGetValue(span.SpanId, out var children)) diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 80ce4b9bed0..a812cc07b05 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -290,6 +290,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new FirstTimeUseNoticeSentinel(GetUsersAspirePath())); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); // MCP server: aspire.dev docs services. diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs index f0eb75f8906..2a1d5ff5f91 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs @@ -219,15 +219,15 @@ internal static string SelectAppHostAction { } } - internal static string HeaderTraceId { + internal static string HeaderTimestamp { get { - return ResourceManager.GetString("HeaderTraceId", resourceCulture); + return ResourceManager.GetString("HeaderTimestamp", resourceCulture); } } - internal static string HeaderResource { + internal static string HeaderName { get { - return ResourceManager.GetString("HeaderResource", resourceCulture); + return ResourceManager.GetString("HeaderName", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx index 9aedfdb474e..fc786ed33df 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -171,11 +171,11 @@ view telemetry for - - Trace Id + + Timestamp - - Resource + + Name Duration diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index 1a1ed4eca5c..b77d2df3fac 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index 2229a93b25d..6132a2e42bf 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index c600cde0a7f..ffdde263163 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index 62b7c524918..56bed81db8f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index 706662c202c..4cb535a3876 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index dcdcd90906a..6dfbda46862 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index 832415dfece..e33e8315c39 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 953b8ad09d7..40e177ca1f7 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index 14a09e42154..6f0f0cd665e 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index 08243acaaee..2b60de02f03 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index 06733344957..e4b0c483738 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index 4f043049a4d..3729489f782 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index a47c1cc3f00..dccc53de57c 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 363e6c47721..b4db21d5100 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -281,6 +281,8 @@ + + diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs index c0cd0d1822f..a5a99249a80 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs @@ -72,20 +72,17 @@ public async IAsyncEnumerable WatchSpansAsync( // The HashSet is cleared after draining to prevent unbounded memory growth. var seenSpanIds = new HashSet(); - // Yield existing spans - foreach (var trace in existingTraces.PagedResult.Items) + // Yield existing spans ordered by start time so streaming clients + // receive the initial snapshot in chronological order. + var existingSpans = existingTraces.PagedResult.Items + .SelectMany(trace => trace.Spans) + .Where(span => resourceKey is null || span.Source.ResourceKey.Equals(resourceKey)) + .OrderBy(span => span.StartTime); + + foreach (var span in existingSpans) { - foreach (var span in trace.Spans) - { - // Filter by resource if specified - if (resourceKey is not null && !span.Source.ResourceKey.Equals(resourceKey)) - { - continue; - } - - seenSpanIds.Add(span.SpanId); - yield return span; - } + seenSpanIds.Add(span.SpanId); + yield return span; } // Drain any spans that arrived during the snapshot to ensure we don't miss them diff --git a/src/Aspire.Dashboard/Utils/FormatHelpers.cs b/src/Shared/FormatHelpers.cs similarity index 88% rename from src/Aspire.Dashboard/Utils/FormatHelpers.cs rename to src/Shared/FormatHelpers.cs index ee3c313492d..4139c0558ee 100644 --- a/src/Aspire.Dashboard/Utils/FormatHelpers.cs +++ b/src/Shared/FormatHelpers.cs @@ -5,7 +5,6 @@ using System.Globalization; using System.Text.RegularExpressions; using Aspire.Dashboard.Extensions; -using Aspire.Dashboard.Model; namespace Aspire.Dashboard.Utils; @@ -76,7 +75,16 @@ static string FormatPattern(CultureDetailsKey key, string millisecondFormat) private static MillisecondFormatString GetShortDateLongTimePatternWithMilliseconds(CultureInfo cultureInfo) => GetMillisecondFormatStrings(cultureInfo).ShortDateLongTimePattern; - public static string FormatTime(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) + /// + /// Formats a DateTime as a local time string (HH:mm:ss.fff) for console output. + /// + public static string FormatConsoleTime(TimeProvider timeProvider, DateTime value) + { + return timeProvider.ToLocal(value) + .ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + public static string FormatTime(TimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) { cultureInfo ??= CultureInfo.CurrentCulture; var local = timeProvider.ToLocal(value); @@ -91,7 +99,7 @@ public static string FormatTime(BrowserTimeProvider timeProvider, DateTime value }; } - public static string FormatDateTime(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) + public static string FormatDateTime(TimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) { cultureInfo ??= CultureInfo.CurrentCulture; var local = timeProvider.ToLocal(value); @@ -106,7 +114,7 @@ public static string FormatDateTime(BrowserTimeProvider timeProvider, DateTime v }; } - public static string FormatTimeWithOptionalDate(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) + public static string FormatTimeWithOptionalDate(TimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) { var local = timeProvider.ToLocal(value); diff --git a/src/Shared/Otlp/OtlpHelpers.cs b/src/Shared/Otlp/OtlpHelpers.cs index e9899f87cd0..372df076cf3 100644 --- a/src/Shared/Otlp/OtlpHelpers.cs +++ b/src/Shared/Otlp/OtlpHelpers.cs @@ -86,20 +86,6 @@ public static TimeSpan CalculateDuration(ulong? startNano, ulong? endNano) return TimeSpan.Zero; } - /// - /// Formats a Unix nanosecond timestamp to a time string (HH:mm:ss.fff). - /// - /// Formatted time string or empty string if null. - public static string FormatNanoTimestamp(ulong? nanos) - { - if (nanos.HasValue) - { - return UnixNanoSecondsToDateTime(nanos.Value) - .ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); - } - return ""; - } - public static string GetResourceName(IOtlpResource resource, IReadOnlyList allResources) { var count = 0; diff --git a/src/Shared/Otlp/Serialization/OtlpResourceJson.cs b/src/Shared/Otlp/Serialization/OtlpResourceJson.cs index 293a4bc271f..38b8cf7a0ee 100644 --- a/src/Shared/Otlp/Serialization/OtlpResourceJson.cs +++ b/src/Shared/Otlp/Serialization/OtlpResourceJson.cs @@ -49,4 +49,25 @@ public string GetServiceName() return "unknown"; } + + /// + /// Gets the service.instance.id attribute value from the resource. + /// + public string? GetServiceInstanceId() + { + if (Attributes is null) + { + return null; + } + + foreach (var attr in Attributes) + { + if (attr.Key == "service.instance.id" && attr.Value?.StringValue is not null) + { + return attr.Value.StringValue; + } + } + + return null; + } } diff --git a/src/Aspire.Dashboard/Extensions/TimeProviderExtensions.cs b/src/Shared/TimeProviderExtensions.cs similarity index 73% rename from src/Aspire.Dashboard/Extensions/TimeProviderExtensions.cs rename to src/Shared/TimeProviderExtensions.cs index 38446ba0865..f1108962fb8 100644 --- a/src/Aspire.Dashboard/Extensions/TimeProviderExtensions.cs +++ b/src/Shared/TimeProviderExtensions.cs @@ -1,13 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Dashboard.Model; - namespace Aspire.Dashboard.Extensions; internal static class TimeProviderExtensions { - public static DateTime ToLocal(this BrowserTimeProvider timeProvider, DateTimeOffset utcDateTimeOffset) + public static DateTime ToLocal(this TimeProvider timeProvider, DateTimeOffset utcDateTimeOffset) { var dateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTimeOffset.UtcDateTime, timeProvider.LocalTimeZone); dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Local); @@ -15,12 +13,12 @@ public static DateTime ToLocal(this BrowserTimeProvider timeProvider, DateTimeOf return dateTime; } - public static DateTimeOffset ToLocalDateTimeOffset(this BrowserTimeProvider timeProvider, DateTimeOffset utcDateTimeOffset) + public static DateTimeOffset ToLocalDateTimeOffset(this TimeProvider timeProvider, DateTimeOffset utcDateTimeOffset) { return TimeZoneInfo.ConvertTime(utcDateTimeOffset, timeProvider.LocalTimeZone); } - public static DateTime ToLocal(this BrowserTimeProvider timeProvider, DateTime dateTime) + public static DateTime ToLocal(this TimeProvider timeProvider, DateTime dateTime) { if (dateTime.Kind == DateTimeKind.Local) { diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs index 0469d945489..df93121e021 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; @@ -27,105 +29,6 @@ public async Task TelemetryCommand_WithoutSubcommand_ReturnsInvalidCommand() Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); } - [Fact] - public async Task TelemetryLogsCommand_WhenNoAppHostRunning_ReturnsSuccess() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("otel logs"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.Success, exitCode); - } - - [Fact] - public async Task TelemetrySpansCommand_WhenNoAppHostRunning_ReturnsSuccess() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("otel spans"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.Success, exitCode); - } - - [Fact] - public async Task TelemetryTracesCommand_WhenNoAppHostRunning_ReturnsSuccess() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("otel traces"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.Success, exitCode); - } - - [Theory] - [InlineData(-1)] - [InlineData(0)] - [InlineData(-100)] - public async Task TelemetryLogsCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse($"telemetry logs --limit {limitValue}"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - } - - [Theory] - [InlineData(-1)] - [InlineData(0)] - [InlineData(-100)] - public async Task TelemetrySpansCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse($"telemetry spans --limit {limitValue}"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - } - - [Theory] - [InlineData(-1)] - [InlineData(0)] - [InlineData(-100)] - public async Task TelemetryTracesCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse($"telemetry traces --limit {limitValue}"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - } - [Fact] public void BuildResourceQueryString_WithNoResources_ReturnsEmptyString() { @@ -190,11 +93,11 @@ public void ToShortenedId_WithShortId_ReturnsOriginal() } [Fact] - public void FormatNanoTimestamp_WithValidTimestamp_ReturnsFormattedTime() + public void FormatConsoleTime_WithValidTimestamp_ReturnsFormattedTime() { // 2026-01-31 12:00:00.123 UTC - var nanoTimestamp = 1769860800123000000UL; - var result = OtlpHelpers.FormatNanoTimestamp(nanoTimestamp); + var dateTime = OtlpHelpers.UnixNanoSecondsToDateTime(1769860800123000000UL); + var result = FormatHelpers.FormatConsoleTime(TimeProvider.System, dateTime); // Result should contain time component (HH:mm:ss.fff) Assert.Matches(@"\d{2}:\d{2}:\d{2}\.\d{3}", result); @@ -240,4 +143,72 @@ public void FormatTraceLink_WithNullDashboardUrl_ReturnsPlainText() Assert.DoesNotContain("[link=", result); Assert.Equal("abc1234", result); // Just the shortened ID } + + [Fact] + public void ToOtlpResources_ConvertsResourceInfoJsonToOtlpResources() + { + var resources = new ResourceInfoJson[] + { + new() { Name = "frontend", InstanceId = "abc123" }, + new() { Name = "backend", InstanceId = null }, + new() { Name = "frontend", InstanceId = "xyz789" }, + }; + + var result = TelemetryCommandHelpers.ToOtlpResources(resources); + + Assert.Equal(3, result.Count); + Assert.Equal("frontend", result[0].ResourceName); + Assert.Equal("abc123", result[0].InstanceId); + Assert.Equal("backend", result[1].ResourceName); + Assert.Null(result[1].InstanceId); + Assert.Equal("frontend", result[2].ResourceName); + Assert.Equal("xyz789", result[2].InstanceId); + + // Empty input yields empty output + Assert.Empty(TelemetryCommandHelpers.ToOtlpResources([])); + } + + [Theory] + [MemberData(nameof(ResolveResourceNameTestData))] + internal void ResolveResourceName_ResolvesExpectedName( + OtlpResourceJson? resource, + IOtlpResource[] allResources, + string expectedName) + { + var result = TelemetryCommandHelpers.ResolveResourceName(resource, allResources); + + Assert.Equal(expectedName, result); + } + + public static IEnumerable ResolveResourceNameTestData() + { + var guid = Guid.Parse("aabbccdd-1122-3344-5566-778899001122"); + var guidStr = guid.ToString(); + + // null resource → "unknown" + yield return [null, Array.Empty(), "unknown"]; + // no attributes → "unknown" + yield return [new OtlpResourceJson { Attributes = null }, new IOtlpResource[] { new SimpleOtlpResource("unknown", null) }, "unknown"]; + // unique service name → bare name + yield return [MakeResource("frontend", "abc123"), new IOtlpResource[] { new SimpleOtlpResource("frontend", "abc123") }, "frontend"]; + // missing instance id, single resource → bare name + yield return [MakeResource("apiservice", null), new IOtlpResource[] { new SimpleOtlpResource("apiservice", null) }, "apiservice"]; + // replicas with non-GUID instance id → name-instanceId + yield return [MakeResource("frontend", "abc123"), new IOtlpResource[] { new SimpleOtlpResource("frontend", "abc123"), new SimpleOtlpResource("frontend", "xyz789") }, "frontend-abc123"]; + // replicas with GUID instance id → name-shortened8chars + yield return [MakeResource("worker", guidStr), new IOtlpResource[] { new SimpleOtlpResource("worker", guidStr), new SimpleOtlpResource("worker", Guid.NewGuid().ToString()) }, $"worker-{guid:N}"[..15]]; + } + + private static OtlpResourceJson MakeResource(string serviceName, string? instanceId) + { + var attrs = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } }, + }; + if (instanceId is not null) + { + attrs.Add(new() { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = instanceId } }); + } + return new OtlpResourceJson { Attributes = [.. attrs] }; + } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs new file mode 100644 index 00000000000..3501a2edc44 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs @@ -0,0 +1,149 @@ +// 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; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class TelemetryLogsCommandTests(ITestOutputHelper outputHelper) +{ + private static readonly DateTime s_testTime = TelemetryTestHelper.s_testTime; + [Fact] + public async Task TelemetryLogsCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetryLogsCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry logs --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task TelemetryLogsCommand_TableOutput_ResolvesUniqueResourceNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "redis", InstanceId = null }, + new ResourceInfoJson { Name = "apiservice", InstanceId = null }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/logs"] = BuildLogsJson( + ("redis", null, 9, "Information", "Ready to accept connections", s_testTime), + ("apiservice", null, 9, "Information", "Request received", s_testTime.AddSeconds(1))) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // With ANSI disabled, output is plain text: "timestamp severity resourceName body" + var logLines = outputWriter.Logs.Where(l => l.Contains("redis") || l.Contains("apiservice")).ToList(); + Assert.Equal(2, logLines.Count); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime)} INFO redis Ready to accept connections", logLines[0]); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddSeconds(1))} INFO apiservice Request received", logLines[1]); + } + + [Fact] + public async Task TelemetryLogsCommand_TableOutput_ResolvesReplicaResourceNames() + { + var guid1 = Guid.Parse("11111111-2222-3333-4444-555555555555"); + var guid2 = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "apiservice", InstanceId = guid1.ToString() }, + new ResourceInfoJson { Name = "apiservice", InstanceId = guid2.ToString() }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/logs"] = BuildLogsJson( + ("apiservice", guid1.ToString(), 9, "Information", "Hello from replica 1", s_testTime), + ("apiservice", guid2.ToString(), 13, "Warning", "Slow response from replica 2", s_testTime.AddSeconds(1))) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Replicas get shortened GUID appended: apiservice-11111111 and apiservice-aaaaaaaa + var logLines = outputWriter.Logs.Where(l => l.Contains("apiservice")).ToList(); + Assert.Equal(2, logLines.Count); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime)} INFO apiservice-11111111 Hello from replica 1", logLines[0]); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddSeconds(1))} WARN apiservice-aaaaaaaa Slow response from replica 2", logLines[1]); + } + + private static string BuildLogsJson(params (string serviceName, string? instanceId, int severityNumber, string severityText, string body, DateTime time)[] entries) + { + var resourceLogs = entries + .GroupBy(e => (e.serviceName, e.instanceId)) + .Select(g => new OtlpResourceLogsJson + { + Resource = TelemetryTestHelper.CreateOtlpResource(g.Key.serviceName, g.Key.instanceId), + ScopeLogs = + [ + new OtlpScopeLogsJson + { + LogRecords = g.Select(e => new OtlpLogRecordJson + { + TimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.time), + SeverityNumber = e.severityNumber, + SeverityText = e.severityText, + Body = new OtlpAnyValueJson { StringValue = e.body } + }).ToArray() + } + ] + }).ToArray(); + + var response = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceLogs = resourceLogs }, + TotalCount = entries.Length, + ReturnedCount = entries.Length + }; + + return JsonSerializer.Serialize(response, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs new file mode 100644 index 00000000000..ac5326b90d6 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs @@ -0,0 +1,151 @@ +// 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; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class TelemetrySpansCommandTests(ITestOutputHelper outputHelper) +{ + private static readonly DateTime s_testTime = TelemetryTestHelper.s_testTime; + [Fact] + public async Task TelemetrySpansCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetrySpansCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry spans --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task TelemetrySpansCommand_TableOutput_ResolvesUniqueResourceNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "frontend", InstanceId = null }, + new ResourceInfoJson { Name = "backend", InstanceId = null }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/spans"] = BuildSpansJson( + ("frontend", null, "span001", "GET /index", s_testTime, s_testTime.AddMilliseconds(50), false), + ("backend", null, "span002", "SELECT * FROM users", s_testTime.AddMilliseconds(10), s_testTime.AddMilliseconds(30), true)) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Span output format: "timestamp STATUS shortSpanId resourceName duration spanName" + var spanLines = outputWriter.Logs.Where(l => l.Contains("frontend") || l.Contains("backend")).ToList(); + Assert.Equal(2, spanLines.Count); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime)} OK 50ms frontend: GET /index span001", spanLines[0]); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddMilliseconds(10))} ERR 20ms backend: SELECT * FROM users span002", spanLines[1]); + } + + [Fact] + public async Task TelemetrySpansCommand_TableOutput_ResolvesReplicaResourceNames() + { + var guid1 = Guid.Parse("11111111-2222-3333-4444-555555555555"); + var guid2 = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "apiservice", InstanceId = guid1.ToString() }, + new ResourceInfoJson { Name = "apiservice", InstanceId = guid2.ToString() }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/spans"] = BuildSpansJson( + ("apiservice", guid1.ToString(), "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(75), false), + ("apiservice", guid2.ToString(), "span002", "POST /api/orders", s_testTime.AddMilliseconds(10), s_testTime.AddMilliseconds(60), true)) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Replicas get shortened GUID appended + var spanLines = outputWriter.Logs.Where(l => l.Contains("apiservice")).ToList(); + Assert.Equal(2, spanLines.Count); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime)} OK 75ms apiservice-11111111: GET /api/products span001", spanLines[0]); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddMilliseconds(10))} ERR 50ms apiservice-aaaaaaaa: POST /api/orders span002", spanLines[1]); + } + + private static string BuildSpansJson(params (string serviceName, string? instanceId, string spanId, string name, DateTime startTime, DateTime endTime, bool hasError)[] entries) + { + var resourceSpans = entries + .GroupBy(e => (e.serviceName, e.instanceId)) + .Select(g => new OtlpResourceSpansJson + { + Resource = TelemetryTestHelper.CreateOtlpResource(g.Key.serviceName, g.Key.instanceId), + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Spans = g.Select(e => new OtlpSpanJson + { + SpanId = e.spanId, + TraceId = "trace001", + Name = e.name, + StartTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.startTime), + EndTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.endTime), + Status = e.hasError ? new OtlpSpanStatusJson { Code = 2 } : new OtlpSpanStatusJson { Code = 1 } + }).ToArray() + } + ] + }).ToArray(); + + var response = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceSpans = resourceSpans }, + TotalCount = entries.Length, + ReturnedCount = entries.Length + }; + + return JsonSerializer.Serialize(response, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryTestHelper.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryTestHelper.cs new file mode 100644 index 00000000000..5adeabac965 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryTestHelper.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aspire.Cli.Tests.Commands; + +internal static class TelemetryTestHelper +{ + /// + /// A fixed base time used by telemetry tests. All test timestamps should be expressed + /// as offsets from this value (e.g. s_testTime.AddMilliseconds(50)). + /// + internal static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Converts a to Unix nanoseconds (nanoseconds since the Unix epoch). + /// + internal static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var timeSinceEpoch = dateTime.ToUniversalTime() - unixEpoch; + + return (ulong)timeSinceEpoch.Ticks * 100; + } + + /// + /// Creates an with the specified service name and optional instance ID. + /// + internal static OtlpResourceJson CreateOtlpResource(string serviceName, string? instanceId) + { + var attrs = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } }, + }; + if (instanceId is not null) + { + attrs.Add(new() { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = instanceId } }); + } + return new OtlpResourceJson { Attributes = [.. attrs] }; + } + + /// + /// Creates a fully configured for telemetry command tests, + /// with a mock backchannel and HTTP handler that serves resource and telemetry data. + /// + /// The temporary workspace for the test. + /// The xUnit test output helper. + /// The test output writer to capture console output. + /// The resource list returned by the /api/telemetry/resources endpoint. + /// + /// A dictionary mapping URL substrings (e.g. "/api/telemetry/logs") to their JSON response content. + /// + internal static ServiceProvider CreateTelemetryTestServices( + TemporaryWorkspace workspace, + ITestOutputHelper outputHelper, + TestOutputTextWriter outputWriter, + ResourceInfoJson[] resources, + Dictionary telemetryEndpoints) + { + var resourcesJson = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 1234 + }, + DashboardInfoResponse = new GetDashboardInfoResponse + { + ApiBaseUrl = "http://localhost:18888", + ApiToken = "test-token", + DashboardUrls = ["http://localhost:18888/login?t=test"], + IsHealthy = true + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesJson, System.Text.Encoding.UTF8, "application/json") + }; + } + + foreach (var (urlPattern, json) in telemetryEndpoints) + { + if (url.Contains(urlPattern)) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + + // Register the handler as a singleton so it is disposed with the ServiceProvider, + // rather than at the end of this method (which would cause ObjectDisposedException). + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + return services.BuildServiceProvider(); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs new file mode 100644 index 00000000000..7e2e3d52e9e --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs @@ -0,0 +1,199 @@ +// 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; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class TelemetryTracesCommandTests(ITestOutputHelper outputHelper) +{ + private static readonly DateTime s_testTime = TelemetryTestHelper.s_testTime; + [Fact] + public async Task TelemetryTracesCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetryTracesCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry traces --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task TelemetryTracesCommand_TableOutput_ResolvesUniqueResourceNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "frontend", InstanceId = null }, + new ResourceInfoJson { Name = "backend", InstanceId = null }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/traces"] = BuildTracesJson( + ("abc1234567890def", "frontend", null, "span001", s_testTime, s_testTime.AddMilliseconds(50), false), + ("def9876543210abc", "backend", null, "span002", s_testTime.AddMilliseconds(10), s_testTime.AddMilliseconds(30), false)) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Parse table data rows for assertion + var dataRows = ParseTableDataRows(outputWriter.Logs); + + // Traces are sorted oldest first: trace1 (s_testTime) first, trace2 (s_testTime+10ms) second + Assert.Equal(2, dataRows.Length); + + Assert.Equal(FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime), dataRows[0][0]); + Assert.Equal("frontend: GET /frontend abc1234", dataRows[0][1]); + Assert.Equal("1", dataRows[0][2]); + Assert.Equal("50ms", dataRows[0][3]); + Assert.Equal("OK", dataRows[0][4]); + + Assert.Equal(FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddMilliseconds(10)), dataRows[1][0]); + Assert.Equal("backend: GET /backend def9876", dataRows[1][1]); + Assert.Equal("1", dataRows[1][2]); + Assert.Equal("20ms", dataRows[1][3]); + Assert.Equal("OK", dataRows[1][4]); + + // Check summary line + var summaryLine = outputWriter.Logs.Last(l => l.StartsWith("Showing", StringComparison.Ordinal)); + Assert.Equal("Showing 2 of 2 traces", summaryLine); + } + + [Fact] + public async Task TelemetryTracesCommand_TableOutput_ResolvesReplicaResourceNames() + { + var guid1 = Guid.Parse("11111111-2222-3333-4444-555555555555"); + var guid2 = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "apiservice", InstanceId = guid1.ToString() }, + new ResourceInfoJson { Name = "apiservice", InstanceId = guid2.ToString() }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/traces"] = BuildTracesJson( + ("abc1234567890def", "apiservice", guid1.ToString(), "span001", s_testTime, s_testTime.AddMilliseconds(75), false), + ("def9876543210abc", "apiservice", guid2.ToString(), "span002", s_testTime.AddMilliseconds(10), s_testTime.AddMilliseconds(60), true)) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Parse table data rows for assertion + var dataRows = ParseTableDataRows(outputWriter.Logs); + + // Traces sorted oldest first: trace1 (s_testTime) first, trace2 (s_testTime+10ms) second + Assert.Equal(2, dataRows.Length); + + Assert.Equal(FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime), dataRows[0][0]); + Assert.Equal("apiservice-11111111: GET /apiservice abc1234", dataRows[0][1]); + Assert.Equal("1", dataRows[0][2]); + Assert.Equal("75ms", dataRows[0][3]); + Assert.Equal("OK", dataRows[0][4]); + + Assert.Equal(FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddMilliseconds(10)), dataRows[1][0]); + Assert.Equal("apiservice-aaaaaaaa: GET /apiservice def9876", dataRows[1][1]); + Assert.Equal("1", dataRows[1][2]); + Assert.Equal("50ms", dataRows[1][3]); + Assert.Equal("ERR", dataRows[1][4]); + + // Check summary line + var summaryLine = outputWriter.Logs.Last(l => l.StartsWith("Showing", StringComparison.Ordinal)); + Assert.Equal("Showing 2 of 2 traces", summaryLine); + } + + /// + /// Parses table rows from Spectre Console output, splitting by │ borders. + /// Returns cell values for each data row (excludes header and border rows). + /// + private static string[][] ParseTableDataRows(IReadOnlyList outputLines) + { + // Find rows with │ separator, then skip the first one which is the header row. + // Border rows contain ─ characters and are also filtered out. + return outputLines + .Where(line => line.Contains('│') && !line.Contains('─') && !line.Contains('┬') && !line.Contains('┼') && !line.Contains('┴')) + .Skip(1) // Skip header row + .Select(line => line.Split('│', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .Where(cells => cells.Length >= 5) + .ToArray(); + } + + private static string BuildTracesJson(params (string traceId, string serviceName, string? instanceId, string spanId, DateTime startTime, DateTime endTime, bool hasError)[] entries) + { + var resourceSpans = entries + .GroupBy(e => (e.serviceName, e.instanceId)) + .Select(g => new OtlpResourceSpansJson + { + Resource = TelemetryTestHelper.CreateOtlpResource(g.Key.serviceName, g.Key.instanceId), + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Spans = g.Select(e => new OtlpSpanJson + { + TraceId = e.traceId, + SpanId = e.spanId, + Name = $"GET /{e.serviceName}", + StartTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.startTime), + EndTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.endTime), + Status = e.hasError ? new OtlpSpanStatusJson { Code = 2 } : new OtlpSpanStatusJson { Code = 1 } + }).ToArray() + } + ] + }).ToArray(); + + var response = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceSpans = resourceSpans }, + TotalCount = entries.Length, + ReturnedCount = entries.Length + }; + + return JsonSerializer.Serialize(response, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 305d4ff4781..32ef1a8de78 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -190,6 +190,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index 4e616956ff1..cac52331be3 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -593,6 +593,96 @@ public async Task WatchLogsAsync_CanBeCancelled() Assert.True(watchTask.IsCompleted); } + [Fact] + public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime() + { + // Arrange + var repository = CreateRepository(); + + // Add spans with non-chronological start times across different traces + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + // Span with latest start time added first + CreateSpan(traceId: "trace1", spanId: "span-late", startTime: s_testTime.AddMinutes(10), endTime: s_testTime.AddMinutes(11)) + } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + // Span with earliest start time added second + CreateSpan(traceId: "trace2", spanId: "span-early", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(2)) + } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + // Span with middle start time added last + CreateSpan(traceId: "trace3", spanId: "span-mid", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(6)) + } + } + } + } + }); + + const int expectedSpans = 3; + + using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(); + using var doneCts = new CancellationTokenSource(); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, doneCts.Token); + var receivedSpans = new List(); + + // Act + try + { + await foreach (var span in repository.WatchSpansAsync(resourceKey: null, linkedCts.Token)) + { + receivedSpans.Add(span); + if (receivedSpans.Count == expectedSpans) + { + doneCts.Cancel(); + } + } + } + catch (OperationCanceledException) + { + // Expected when all spans received + } + + // Assert - spans should be ordered by start time regardless of insertion order + Assert.Equal(expectedSpans, receivedSpans.Count); + Assert.Equal("Test span. Id: span-early", receivedSpans[0].Name); + Assert.Equal("Test span. Id: span-mid", receivedSpans[1].Name); + Assert.Equal("Test span. Id: span-late", receivedSpans[2].Name); + } + [Fact] public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() { From ee83e994fd0b23fab035e7304201e2cbbe424378 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 2 Mar 2026 13:39:59 -0500 Subject: [PATCH 02/18] Cherry-pick VS Code extension fixes from main to release/13.2 (#14846) * Detect CLI at default install paths when not on PATH (#14545) Check default installation directories (~/.aspire/bin, ~/.dotnet/tools) when the Aspire CLI is not found on the system PATH. If found at a default location, the VS Code setting is auto-updated. If later found on PATH, the setting is cleared. Resolution order: configured custom path > system PATH > default install paths. Fixes #14235 * Fix JSON-RPC error on disconnect and auto-restart Aspire debug session on AppHost restart (#14548) * Fix JSON-RPC error on disconnect and auto-restart debug session on AppHost restart * Update extension/src/debugger/AspireDebugSession.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extension/src/debugger/AspireDebugSession.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extension/loc/xlf/aspire-vscode.xlf | 3 + extension/package.nls.json | 1 + extension/src/commands/add.ts | 2 +- extension/src/commands/deploy.ts | 2 +- extension/src/commands/init.ts | 2 +- extension/src/commands/new.ts | 2 +- extension/src/commands/publish.ts | 2 +- extension/src/commands/update.ts | 2 +- .../AspireDebugConfigurationProvider.ts | 12 +- extension/src/debugger/AspireDebugSession.ts | 30 ++- extension/src/extension.ts | 7 +- extension/src/loc/strings.ts | 1 + .../src/test/aspireTerminalProvider.test.ts | 76 ++----- extension/src/test/cliPath.test.ts | 211 ++++++++++++++++++ extension/src/utils/AspireTerminalProvider.ts | 17 +- extension/src/utils/cliPath.ts | 194 ++++++++++++++++ extension/src/utils/configInfoProvider.ts | 4 +- extension/src/utils/workspace.ts | 71 +++--- 18 files changed, 505 insertions(+), 134 deletions(-) create mode 100644 extension/src/test/cliPath.test.ts create mode 100644 extension/src/utils/cliPath.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 73088ed32fb..bbe98c2cc15 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,6 +10,9 @@ Aspire CLI Version: {0}. + + Aspire CLI found at {0}. The extension will use this path. + Aspire CLI is not available on PATH. Please install it and restart VS Code. diff --git a/extension/package.nls.json b/extension/package.nls.json index 75e0719f912..03c1794715e 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -93,6 +93,7 @@ "aspire-vscode.strings.lookingForDevkitBuildTask": "C# Dev Kit is installed, looking for C# Dev Kit build task...", "aspire-vscode.strings.csharpDevKitNotInstalled": "C# Dev Kit is not installed, building using dotnet CLI...", "aspire-vscode.strings.cliNotAvailable": "Aspire CLI is not available on PATH. Please install it and restart VS Code.", + "aspire-vscode.strings.cliFoundAtDefaultPath": "Aspire CLI found at {0}. The extension will use this path.", "aspire-vscode.strings.openCliInstallInstructions": "See CLI installation instructions", "aspire-vscode.strings.dismissLabel": "Dismiss" } diff --git a/extension/src/commands/add.ts b/extension/src/commands/add.ts index 5d8bd3307a7..e1e158d7b4b 100644 --- a/extension/src/commands/add.ts +++ b/extension/src/commands/add.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function addCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('add'); + await terminalProvider.sendAspireCommandToAspireTerminal('add'); } diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index a40590e1891..057d419f6ca 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function deployCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('deploy'); + await terminalProvider.sendAspireCommandToAspireTerminal('deploy'); } diff --git a/extension/src/commands/init.ts b/extension/src/commands/init.ts index 642bfa23aa3..3d6c60e25d9 100644 --- a/extension/src/commands/init.ts +++ b/extension/src/commands/init.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; export async function initCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('init'); + await terminalProvider.sendAspireCommandToAspireTerminal('init'); }; \ No newline at end of file diff --git a/extension/src/commands/new.ts b/extension/src/commands/new.ts index d8a26eab433..ab2936e0af3 100644 --- a/extension/src/commands/new.ts +++ b/extension/src/commands/new.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; export async function newCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('new'); + await terminalProvider.sendAspireCommandToAspireTerminal('new'); }; diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 181d590337a..276ea03a7a8 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function publishCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('publish'); + await terminalProvider.sendAspireCommandToAspireTerminal('publish'); } diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index 31ab5b9f89e..23e8070920e 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function updateCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('update'); + await terminalProvider.sendAspireCommandToAspireTerminal('update'); } diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index ba4c8d98c14..643db6ed958 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -1,15 +1,8 @@ import * as vscode from 'vscode'; import { defaultConfigurationName } from '../loc/strings'; -import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider { - private _terminalProvider: AspireTerminalProvider; - - constructor(terminalProvider: AspireTerminalProvider) { - this._terminalProvider = terminalProvider; - } - async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise { if (folder === undefined) { return []; @@ -28,9 +21,8 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { // Check if CLI is available before starting debug session - const cliPath = this._terminalProvider.getAspireCliExecutablePath(); - const isCliAvailable = await checkCliAvailableOrRedirect(cliPath); - if (!isCliAvailable) { + const result = await checkCliAvailableOrRedirect(); + if (!result.available) { return undefined; // Cancel the debug session } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index bc35aceeb6c..434eb0cf793 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -29,6 +29,8 @@ export class AspireDebugSession implements vscode.DebugAdapter { private _trackedDebugAdapters: string[] = []; private _rpcClient?: ICliRpcClient; private readonly _disposables: vscode.Disposable[] = []; + private _disposed = false; + private _userInitiatedStop = false; public readonly onDidSendMessage = this._onDidSendMessage.event; public readonly debugSessionId: string; @@ -93,18 +95,19 @@ export class AspireDebugSession implements vscode.DebugAdapter { if (isDirectory(appHostPath)) { this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath)); - this.spawnRunCommand(args, appHostPath, noDebug); + void this.spawnRunCommand(args, appHostPath, noDebug); } else { this.sendMessageWithEmoji("📂", launchingWithAppHost(appHostPath)); const workspaceFolder = path.dirname(appHostPath); args.push('--project', appHostPath); - this.spawnRunCommand(args, workspaceFolder, noDebug); + void this.spawnRunCommand(args, workspaceFolder, noDebug); } } else if (message.command === 'disconnect' || message.command === 'terminate') { this.sendMessageWithEmoji("🔌", disconnectingFromSession); + this._userInitiatedStop = true; this.dispose(); this.sendEvent({ @@ -133,7 +136,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { } } - spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { + async spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { const disposable = this._rpcServer.onNewConnection((client: ICliRpcClient) => { if (client.debugSessionId === this.debugSessionId) { this._rpcClient = client; @@ -143,7 +146,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { spawnCliProcess( this._terminalProvider, - this._terminalProvider.getAspireCliExecutablePath(), + await this._terminalProvider.getAspireCliExecutablePath(), args, { stdoutCallback: (data) => { @@ -173,7 +176,9 @@ export class AspireDebugSession implements vscode.DebugAdapter { this._disposables.push({ dispose: () => { - this._rpcClient?.stopCli(); + this._rpcClient?.stopCli().catch((err) => { + extensionLogOutputChannel.info(`stopCli failed (connection may already be closed): ${err}`); + }); extensionLogOutputChannel.info(`Requested Aspire CLI exit with args: ${args.join(' ')}`); } }); @@ -219,9 +224,15 @@ export class AspireDebugSession implements vscode.DebugAdapter { const disposable = vscode.debug.onDidTerminateDebugSession(async session => { if (this._appHostDebugSession && session.id === this._appHostDebugSession.id) { - // We should also dispose of the parent Aspire debug session whenever the AppHost stops. + const shouldRestart = !this._userInitiatedStop; + const config = this.configuration; + // Always dispose the current Aspire debug session when the AppHost stops. this.dispose(); - disposable.dispose(); + + if (shouldRestart) { + extensionLogOutputChannel.info('AppHost terminated unexpectedly, restarting Aspire debug session'); + await vscode.debug.startDebugging(undefined, config); + } } }); @@ -281,11 +292,14 @@ export class AspireDebugSession implements vscode.DebugAdapter { } dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; extensionLogOutputChannel.info('Stopping the Aspire debug session'); vscode.debug.stopDebugging(this._session); this._disposables.forEach(disposable => disposable.dispose()); this._trackedDebugAdapters = []; - this._rpcClient?.stopCli(); } private sendResponse(request: any, body: any = {}) { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index f2e2c44f8eb..de001575696 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -67,7 +67,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration); context.subscriptions.push(cliUpdateCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); - const debugConfigProvider = new AspireDebugConfigurationProvider(terminalProvider); + const debugConfigProvider = new AspireDebugConfigurationProvider(); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) ); @@ -114,9 +114,8 @@ async function tryExecuteCommand(commandName: string, terminalProvider: AspireTe const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson"]; if (!cliCheckExcludedCommands.includes(commandName)) { - const cliPath = terminalProvider.getAspireCliExecutablePath(); - const isCliAvailable = await checkCliAvailableOrRedirect(cliPath); - if (!isCliAvailable) { + const result = await checkCliAvailableOrRedirect(); + if (!result.available) { return; } } diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 484ca92ec30..1b02e953ff7 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -71,3 +71,4 @@ export const csharpDevKitNotInstalled = vscode.l10n.t('C# Dev Kit is not install export const dismissLabel = vscode.l10n.t('Dismiss'); export const openCliInstallInstructions = vscode.l10n.t('See CLI installation instructions'); export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.'); +export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path); diff --git a/extension/src/test/aspireTerminalProvider.test.ts b/extension/src/test/aspireTerminalProvider.test.ts index dc70ca4c3fb..fa139b51715 100644 --- a/extension/src/test/aspireTerminalProvider.test.ts +++ b/extension/src/test/aspireTerminalProvider.test.ts @@ -2,94 +2,58 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import * as cliPathModule from '../utils/cliPath'; suite('AspireTerminalProvider tests', () => { let terminalProvider: AspireTerminalProvider; - let configStub: sinon.SinonStub; + let resolveCliPathStub: sinon.SinonStub; let subscriptions: vscode.Disposable[]; setup(() => { subscriptions = []; terminalProvider = new AspireTerminalProvider(subscriptions); - configStub = sinon.stub(vscode.workspace, 'getConfiguration'); + resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath'); }); teardown(() => { - configStub.restore(); + resolveCliPathStub.restore(); subscriptions.forEach(s => s.dispose()); }); suite('getAspireCliExecutablePath', () => { - test('returns "aspire" when no custom path is configured', () => { - configStub.returns({ - get: sinon.stub().returns('') - }); + test('returns "aspire" when CLI is on PATH', async () => { + resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'aspire'); }); - test('returns custom path when configured', () => { - configStub.returns({ - get: sinon.stub().returns('/usr/local/bin/aspire') - }); + test('returns resolved path when CLI found at default install location', async () => { + resolveCliPathStub.resolves({ cliPath: '/home/user/.aspire/bin/aspire', available: true, source: 'default-install' }); - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/usr/local/bin/aspire'); + const result = await terminalProvider.getAspireCliExecutablePath(); + assert.strictEqual(result, '/home/user/.aspire/bin/aspire'); }); - test('returns custom path with spaces', () => { - configStub.returns({ - get: sinon.stub().returns('/my path/with spaces/aspire') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/my path/with spaces/aspire'); - }); + test('returns configured custom path', async () => { + resolveCliPathStub.resolves({ cliPath: '/usr/local/bin/aspire', available: true, source: 'configured' }); - test('trims whitespace from configured path', () => { - configStub.returns({ - get: sinon.stub().returns(' /usr/local/bin/aspire ') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, '/usr/local/bin/aspire'); }); - test('returns "aspire" when configured path is only whitespace', () => { - configStub.returns({ - get: sinon.stub().returns(' ') - }); + test('returns "aspire" when CLI is not found', async () => { + resolveCliPathStub.resolves({ cliPath: 'aspire', available: false, source: 'not-found' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'aspire'); }); - test('handles Windows-style paths', () => { - configStub.returns({ - get: sinon.stub().returns('C:\\Program Files\\Aspire\\aspire.exe') - }); + test('handles Windows-style paths', async () => { + resolveCliPathStub.resolves({ cliPath: 'C:\\Program Files\\Aspire\\aspire.exe', available: true, source: 'configured' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'C:\\Program Files\\Aspire\\aspire.exe'); }); - - test('handles Windows-style paths without spaces', () => { - configStub.returns({ - get: sinon.stub().returns('C:\\aspire\\aspire.exe') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, 'C:\\aspire\\aspire.exe'); - }); - - test('handles paths with special characters', () => { - configStub.returns({ - get: sinon.stub().returns('/path/with$dollar/aspire') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/path/with$dollar/aspire'); - }); }); }); diff --git a/extension/src/test/cliPath.test.ts b/extension/src/test/cliPath.test.ts new file mode 100644 index 00000000000..e70519b3ebe --- /dev/null +++ b/extension/src/test/cliPath.test.ts @@ -0,0 +1,211 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as os from 'os'; +import * as path from 'path'; +import { getDefaultCliInstallPaths, resolveCliPath, CliPathDependencies } from '../utils/cliPath'; + +const bundlePath = '/home/user/.aspire/bin/aspire'; +const globalToolPath = '/home/user/.dotnet/tools/aspire'; +const defaultPaths = [bundlePath, globalToolPath]; + +function createMockDeps(overrides: Partial = {}): CliPathDependencies { + return { + getConfiguredPath: () => '', + getDefaultPaths: () => defaultPaths, + isOnPath: async () => false, + findAtDefaultPath: async () => undefined, + tryExecute: async () => false, + setConfiguredPath: async () => {}, + ...overrides, + }; +} + +suite('utils/cliPath tests', () => { + + suite('getDefaultCliInstallPaths', () => { + test('returns bundle path (~/.aspire/bin) as first entry', () => { + const paths = getDefaultCliInstallPaths(); + const homeDir = os.homedir(); + + assert.ok(paths.length >= 2, 'Should return at least 2 default paths'); + assert.ok(paths[0].startsWith(path.join(homeDir, '.aspire', 'bin')), `First path should be bundle install: ${paths[0]}`); + }); + + test('returns global tool path (~/.dotnet/tools) as second entry', () => { + const paths = getDefaultCliInstallPaths(); + const homeDir = os.homedir(); + + assert.ok(paths[1].startsWith(path.join(homeDir, '.dotnet', 'tools')), `Second path should be global tool: ${paths[1]}`); + }); + + test('uses correct executable name for current platform', () => { + const paths = getDefaultCliInstallPaths(); + + for (const p of paths) { + const basename = path.basename(p); + if (process.platform === 'win32') { + assert.strictEqual(basename, 'aspire.exe'); + } else { + assert.strictEqual(basename, 'aspire'); + } + } + }); + }); + + suite('resolveCliPath', () => { + test('falls back to default install path when CLI is not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'default-install'); + assert.strictEqual(result.cliPath, bundlePath); + assert.ok(setConfiguredPath.calledOnceWith(bundlePath), 'should update the VS Code setting to the found path'); + }); + + test('updates VS Code setting when CLI found at default path but not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => '', + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + await resolveCliPath(deps); + + assert.ok(setConfiguredPath.calledOnce, 'setConfiguredPath should be called once'); + assert.strictEqual(setConfiguredPath.firstCall.args[0], bundlePath, 'should set the path to the found install location'); + }); + + test('prefers PATH over default install path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + isOnPath: async () => true, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'path'); + assert.strictEqual(result.cliPath, 'aspire'); + assert.ok(setConfiguredPath.notCalled, 'should not update settings when CLI is on PATH'); + }); + + test('clears setting when CLI is on PATH and setting was previously set to a default path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => bundlePath, + isOnPath: async () => true, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting'); + }); + + test('clears setting when CLI is on PATH and setting was previously set to global tool path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => globalToolPath, + isOnPath: async () => true, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting'); + }); + + test('returns not-found when CLI is not on PATH and not at any default path', async () => { + const deps = createMockDeps({ + isOnPath: async () => false, + findAtDefaultPath: async () => undefined, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, false); + assert.strictEqual(result.source, 'not-found'); + }); + + test('uses custom configured path when valid and not a default', async () => { + const customPath = '/custom/path/aspire'; + + const deps = createMockDeps({ + getConfiguredPath: () => customPath, + tryExecute: async (p) => p === customPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'configured'); + assert.strictEqual(result.cliPath, customPath); + }); + + test('falls through to PATH check when custom configured path is invalid', async () => { + const deps = createMockDeps({ + getConfiguredPath: () => '/bad/path/aspire', + tryExecute: async () => false, + isOnPath: async () => true, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.strictEqual(result.available, true); + }); + + test('falls through to default path when custom configured path is invalid and not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => '/bad/path/aspire', + tryExecute: async () => false, + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'default-install'); + assert.strictEqual(result.cliPath, bundlePath); + assert.ok(setConfiguredPath.calledOnceWith(bundlePath)); + }); + + test('does not update setting when already set to the found default path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => bundlePath, + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'default-install'); + assert.ok(setConfiguredPath.notCalled, 'should not re-set the path if it already matches'); + }); + }); +}); + diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index 35762287729..95ed6bf5426 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -5,6 +5,7 @@ import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; import { DcpServerConnectionInfo } from '../dcp/types'; import { getRunSessionInfo, getSupportedCapabilities } from '../capabilities'; import { EnvironmentVariables } from './environment'; +import { resolveCliPath } from './cliPath'; import path from 'path'; export const enum AnsiColors { @@ -57,8 +58,8 @@ export class AspireTerminalProvider implements vscode.Disposable { this._dcpServerConnectionInfo = value; } - sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { - const cliPath = this.getAspireCliExecutablePath(); + async sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { + const cliPath = await this.getAspireCliExecutablePath(); // On Windows, use & to execute paths, especially those with special characters // On Unix, just use the path directly @@ -200,15 +201,9 @@ export class AspireTerminalProvider implements vscode.Disposable { } - getAspireCliExecutablePath(): string { - const aspireCliPath = vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', ''); - if (aspireCliPath && aspireCliPath.trim().length > 0) { - extensionLogOutputChannel.debug(`Using user-configured Aspire CLI path: ${aspireCliPath}`); - return aspireCliPath.trim(); - } - - extensionLogOutputChannel.debug('No user-configured Aspire CLI path found'); - return "aspire"; + async getAspireCliExecutablePath(): Promise { + const result = await resolveCliPath(); + return result.cliPath; } isCliDebugLoggingEnabled(): boolean { diff --git a/extension/src/utils/cliPath.ts b/extension/src/utils/cliPath.ts new file mode 100644 index 00000000000..6290ac6d945 --- /dev/null +++ b/extension/src/utils/cliPath.ts @@ -0,0 +1,194 @@ +import * as vscode from 'vscode'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { extensionLogOutputChannel } from './logging'; + +const execFileAsync = promisify(execFile); +const fsAccessAsync = promisify(fs.access); + +/** + * Gets the default installation paths for the Aspire CLI, in priority order. + * + * The CLI can be installed in two ways: + * 1. Bundle install (recommended): ~/.aspire/bin/aspire + * 2. .NET global tool: ~/.dotnet/tools/aspire + * + * @returns An array of default CLI paths to check, ordered by priority + */ +export function getDefaultCliInstallPaths(): string[] { + const homeDir = os.homedir(); + const exeName = process.platform === 'win32' ? 'aspire.exe' : 'aspire'; + + return [ + // Bundle install (recommended): ~/.aspire/bin/aspire + path.join(homeDir, '.aspire', 'bin', exeName), + // .NET global tool: ~/.dotnet/tools/aspire + path.join(homeDir, '.dotnet', 'tools', exeName), + ]; +} + +/** + * Checks if a file exists and is accessible. + */ +async function fileExists(filePath: string): Promise { + try { + await fsAccessAsync(filePath, fs.constants.F_OK); + return true; + } + catch { + return false; + } +} + +/** + * Tries to execute the CLI at the given path to verify it works. + */ +async function tryExecuteCli(cliPath: string): Promise { + try { + await execFileAsync(cliPath, ['--version'], { timeout: 5000 }); + return true; + } + catch { + return false; + } +} + +/** + * Checks if the Aspire CLI is available on the system PATH. + */ +export async function isCliOnPath(): Promise { + return await tryExecuteCli('aspire'); +} + +/** + * Finds the first default installation path where the Aspire CLI exists and is executable. + * + * @returns The path where CLI was found, or undefined if not found at any default location + */ +export async function findCliAtDefaultPath(): Promise { + for (const defaultPath of getDefaultCliInstallPaths()) { + if (await fileExists(defaultPath) && await tryExecuteCli(defaultPath)) { + return defaultPath; + } + } + + return undefined; +} + +/** + * Gets the VS Code configuration setting for the Aspire CLI path. + */ +export function getConfiguredCliPath(): string { + return vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', '').trim(); +} + +/** + * Updates the VS Code configuration setting for the Aspire CLI path. + * Uses ConfigurationTarget.Global to set it at the user level. + */ +export async function setConfiguredCliPath(cliPath: string): Promise { + extensionLogOutputChannel.info(`Setting aspire.aspireCliExecutablePath to: ${cliPath || '(empty)'}`); + await vscode.workspace.getConfiguration('aspire').update( + 'aspireCliExecutablePath', + cliPath || undefined, // Use undefined to remove the setting + vscode.ConfigurationTarget.Global + ); +} + +/** + * Result of checking CLI availability. + */ +export interface CliPathResolutionResult { + /** The resolved CLI path to use */ + cliPath: string; + /** Whether the CLI is available */ + available: boolean; + /** Where the CLI was found */ + source: 'path' | 'default-install' | 'configured' | 'not-found'; +} + +/** + * Dependencies for resolveCliPath that can be overridden for testing. + */ +export interface CliPathDependencies { + getConfiguredPath: () => string; + getDefaultPaths: () => string[]; + isOnPath: () => Promise; + findAtDefaultPath: () => Promise; + tryExecute: (cliPath: string) => Promise; + setConfiguredPath: (cliPath: string) => Promise; +} + +const defaultDependencies: CliPathDependencies = { + getConfiguredPath: getConfiguredCliPath, + getDefaultPaths: getDefaultCliInstallPaths, + isOnPath: isCliOnPath, + findAtDefaultPath: findCliAtDefaultPath, + tryExecute: tryExecuteCli, + setConfiguredPath: setConfiguredCliPath, +}; + +/** + * Resolves the Aspire CLI path, checking multiple locations in order: + * 1. User-configured path in VS Code settings + * 2. System PATH + * 3. Default installation directories (~/.aspire/bin, ~/.dotnet/tools) + * + * If the CLI is found at a default installation path but not on PATH, + * the VS Code setting is updated to use that path. + * + * If the CLI is on PATH and a setting was previously auto-configured to a default path, + * the setting is cleared to prefer PATH. + */ +export async function resolveCliPath(deps: CliPathDependencies = defaultDependencies): Promise { + const configuredPath = deps.getConfiguredPath(); + const defaultPaths = deps.getDefaultPaths(); + + // 1. Check if user has configured a custom path (not one of the defaults) + if (configuredPath && !defaultPaths.includes(configuredPath)) { + const isValid = await deps.tryExecute(configuredPath); + if (isValid) { + extensionLogOutputChannel.info(`Using user-configured Aspire CLI path: ${configuredPath}`); + return { cliPath: configuredPath, available: true, source: 'configured' }; + } + + extensionLogOutputChannel.warn(`Configured CLI path is invalid: ${configuredPath}`); + // Continue to check other locations + } + + // 2. Check if CLI is on PATH + const onPath = await deps.isOnPath(); + if (onPath) { + extensionLogOutputChannel.info('Aspire CLI found on system PATH'); + + // If we previously auto-set the path to a default install location, clear it + // since PATH is now working + if (defaultPaths.includes(configuredPath)) { + extensionLogOutputChannel.info('Clearing aspireCliExecutablePath setting since CLI is on PATH'); + await deps.setConfiguredPath(''); + } + + return { cliPath: 'aspire', available: true, source: 'path' }; + } + + // 3. Check default installation paths (~/.aspire/bin first, then ~/.dotnet/tools) + const foundPath = await deps.findAtDefaultPath(); + if (foundPath) { + extensionLogOutputChannel.info(`Aspire CLI found at default install location: ${foundPath}`); + + // Update the setting so future invocations use this path + if (configuredPath !== foundPath) { + extensionLogOutputChannel.info('Updating aspireCliExecutablePath setting to use default install location'); + await deps.setConfiguredPath(foundPath); + } + + return { cliPath: foundPath, available: true, source: 'default-install' }; + } + + // 4. CLI not found anywhere + extensionLogOutputChannel.warn('Aspire CLI not found on PATH or at default install locations'); + return { cliPath: 'aspire', available: false, source: 'not-found' }; +} diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index ca9f4ea3c64..bd342a5feb5 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -9,11 +9,13 @@ import * as strings from '../loc/strings'; * Gets configuration information from the Aspire CLI. */ export async function getConfigInfo(terminalProvider: AspireTerminalProvider): Promise { + const cliPath = await terminalProvider.getAspireCliExecutablePath(); + return new Promise((resolve) => { const args = ['config', 'info', '--json']; let output = ''; - spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, { + spawnCliProcess(terminalProvider, cliPath, args, { stdoutCallback: (data) => { output += data; }, diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 302b11dc716..f1335aa87d4 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -1,13 +1,13 @@ import * as vscode from 'vscode'; -import { cliNotAvailable, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings'; +import { cliNotAvailable, cliFoundAtDefaultPath, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings'; import path from 'path'; import { spawnCliProcess } from '../debugger/languages/cli'; import { AspireTerminalProvider } from './AspireTerminalProvider'; -import { ChildProcessWithoutNullStreams, execFile } from 'child_process'; +import { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireSettingsFile } from './cliTypes'; import { extensionLogOutputChannel } from './logging'; import { EnvironmentVariables } from './environment'; -import { promisify } from 'util'; +import { resolveCliPath } from './cliPath'; /** * Common file patterns to exclude from workspace file searches. @@ -158,13 +158,14 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire extension get-apphosts'); let proc: ChildProcessWithoutNullStreams; + const cliPath = await terminalProvider.getAspireCliExecutablePath(); new Promise((resolve, reject) => { const args = ['extension', 'get-apphosts']; if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { args.push('--cli-wait-for-debugger'); } - proc = spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, { + proc = spawnCliProcess(terminalProvider, cliPath, args, { errorCallback: error => { extensionLogOutputChannel.error(`Error executing get-apphosts command: ${error}`); reject(); @@ -268,44 +269,38 @@ async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearch extensionLogOutputChannel.info(`Successfully set appHostPath to: ${appHostToUse} in ${settingsFileLocation.fsPath}`); } -const execFileAsync = promisify(execFile); - -let cliAvailableOnPath: boolean | undefined = undefined; - /** - * Checks if the Aspire CLI is available. If not, shows a message prompting to open Aspire CLI installation steps on the repo. - * @param cliPath The path to the Aspire CLI executable - * @returns true if CLI is available, false otherwise + * Checks if the Aspire CLI is available. If not found on PATH, it checks the default + * installation directory and updates the VS Code setting accordingly. + * + * If not available, shows a message prompting to open Aspire CLI installation steps. + * @returns An object containing the CLI path to use and whether CLI is available */ -export async function checkCliAvailableOrRedirect(cliPath: string): Promise { - if (cliAvailableOnPath === true) { - // Assume, for now, that CLI availability does not change during the session if it was previously confirmed - return Promise.resolve(true); +export async function checkCliAvailableOrRedirect(): Promise<{ cliPath: string; available: boolean }> { + // Resolve CLI path fresh each time — settings or PATH may have changed + const result = await resolveCliPath(); + + if (result.available) { + // Show informational message if CLI was found at default path (not on PATH) + if (result.source === 'default-install') { + extensionLogOutputChannel.info(`Using Aspire CLI from default install location: ${result.cliPath}`); + vscode.window.showInformationMessage(cliFoundAtDefaultPath(result.cliPath)); + } + + return { cliPath: result.cliPath, available: true }; } - try { - // Remove surrounding quotes if present (both single and double quotes) - let cleanPath = cliPath.trim(); - if ((cleanPath.startsWith("'") && cleanPath.endsWith("'")) || - (cleanPath.startsWith('"') && cleanPath.endsWith('"'))) { - cleanPath = cleanPath.slice(1, -1); + // CLI not found - show error message with install instructions + vscode.window.showErrorMessage( + cliNotAvailable, + openCliInstallInstructions, + dismissLabel + ).then(selection => { + if (selection === openCliInstallInstructions) { + // Go to Aspire CLI installation instruction page in external browser + vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/')); } - await execFileAsync(cleanPath, ['--version'], { timeout: 5000 }); - cliAvailableOnPath = true; - return true; - } catch (error) { - cliAvailableOnPath = false; - vscode.window.showErrorMessage( - cliNotAvailable, - openCliInstallInstructions, - dismissLabel - ).then(selection => { - if (selection === openCliInstallInstructions) { - // Go to Aspire CLI installation instruction page in external browser - vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/')); - } - }); + }); - return false; - } + return { cliPath: result.cliPath, available: false }; } From 7ca15d6c0ac0841191b2d5a2d8cb237ce8189449 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 2 Mar 2026 13:40:22 -0500 Subject: [PATCH 03/18] Fix extension command table in README to match actual commands (#14687) Update the command table in extension/README.md to be consistent with the actual command titles registered in package.nls.json: - Fix 'Configure launch.json' -> 'Configure launch.json file' - Fix 'Manage configuration settings' -> 'Extension settings' - Add missing commands: Initialize Aspire, Open local/global Aspire settings - Remove outdated Available/Preview availability column --- extension/README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/extension/README.md b/extension/README.md index 0fe685b2275..7d8442ae1e8 100644 --- a/extension/README.md +++ b/extension/README.md @@ -7,16 +7,19 @@ The Aspire VS Code extension provides a set of commands and tools to help you wo The extension adds the following commands to VS Code: -| Command | Description | Availability | -|---------|-------------|--------------| -| Aspire: New Aspire project | Create a new Aspire apphost or starter app from a template. | Available | -| Aspire: Add an integration | Add a hosting integration (`Aspire.Hosting.*`) to the Aspire apphost. | Available | -| Aspire: Configure launch.json | Adds the default Aspire debugger launch configuration to your workspace's `launch.json`, which will detect and run the apphost in the workspace. | Available | -| Aspire: Manage configuration settings | Manage configuration settings including feature flags. | Available | -| Aspire: Open Aspire terminal | Open an Aspire VS Code terminal for working with Aspire projects. | Available | -| Aspire: Publish deployment artifacts | Generates deployment artifacts for an Aspire apphost. | Preview | -| Aspire: Deploy app | Deploy the contents of an Aspire apphost to its defined deployment targets. | Preview | -| Aspire: Update integrations | Update hosting integrations and Aspire SDK in the apphost. | Preview | +| Command | Description | +|---------|-------------| +| Aspire: New Aspire project | Create a new Aspire apphost or starter app from a template. | +| Aspire: Initialize Aspire | Initialize Aspire in an existing project. | +| Aspire: Add an integration | Add a hosting integration (`Aspire.Hosting.*`) to the Aspire apphost. | +| Aspire: Update integrations | Update hosting integrations and Aspire SDK in the apphost. | +| Aspire: Publish deployment artifacts | Generate deployment artifacts for an Aspire apphost. | +| Aspire: Deploy app | Deploy the contents of an Aspire apphost to its defined deployment targets. | +| Aspire: Configure launch.json file | Add the default Aspire debugger launch configuration to your workspace's `launch.json`. | +| Aspire: Extension settings | Open Aspire extension settings. | +| Aspire: Open local Aspire settings | Open the local `.aspire/settings.json` file for the current workspace. | +| Aspire: Open global Aspire settings | Open the global `~/.aspire/globalsettings.json` file. | +| Aspire: Open Aspire terminal | Open an Aspire VS Code terminal for working with Aspire projects. | All commands are available from the Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`) and are grouped under the "Aspire" category. From f65c4f06fe556f4812072d6cc8c7a69d8978c479 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 2 Mar 2026 16:31:37 -0500 Subject: [PATCH 04/18] Use VS Code file pickers instead of prompting for strings (#14556) Add PromptForFilePathAsync to IInteractionService that uses native OS file pickers when the VS Code extension supports the file-pickers.v1 capability. Falls back to PromptForStringAsync for older extensions or console mode. Call sites updated: AgentInitCommand, NewCommand, ProjectUpdater. Fixes #12758 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/loc/xlf/aspire-vscode.xlf | 6 +++ extension/package.nls.json | 4 +- extension/src/capabilities.ts | 3 +- extension/src/loc/strings.ts | 2 + extension/src/server/interactionService.ts | 26 +++++++++- .../Backchannel/ExtensionBackchannel.cs | 19 +++++++ src/Aspire.Cli/Commands/AgentInitCommand.cs | 3 +- src/Aspire.Cli/Commands/NewCommand.cs | 3 +- .../Interaction/ConsoleInteractionService.cs | 5 ++ .../ExtensionInteractionService.cs | 52 +++++++++++++++++++ .../Interaction/IInteractionService.cs | 1 + src/Aspire.Cli/Projects/ProjectUpdater.cs | 4 +- src/Aspire.Cli/Utils/ExtensionHelper.cs | 1 + .../Commands/NewCommandTests.cs | 4 +- ...PublishCommandPromptingIntegrationTests.cs | 3 ++ .../Commands/UpdateCommandTests.cs | 2 + .../Templating/DotNetTemplateFactoryTests.cs | 3 ++ .../TestConsoleInteractionService.cs | 5 ++ .../TestServices/TestExtensionBackchannel.cs | 11 ++++ .../TestExtensionInteractionService.cs | 5 ++ 20 files changed, 154 insertions(+), 8 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index bbe98c2cc15..09d7dbe9ed0 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -292,5 +292,11 @@ Yes + + Select directory + + + Select file + \ No newline at end of file diff --git a/extension/package.nls.json b/extension/package.nls.json index 03c1794715e..50e29832bcf 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -95,5 +95,7 @@ "aspire-vscode.strings.cliNotAvailable": "Aspire CLI is not available on PATH. Please install it and restart VS Code.", "aspire-vscode.strings.cliFoundAtDefaultPath": "Aspire CLI found at {0}. The extension will use this path.", "aspire-vscode.strings.openCliInstallInstructions": "See CLI installation instructions", - "aspire-vscode.strings.dismissLabel": "Dismiss" + "aspire-vscode.strings.dismissLabel": "Dismiss", + "aspire-vscode.strings.selectDirectoryTitle": "Select directory", + "aspire-vscode.strings.selectFileTitle": "Select file" } diff --git a/extension/src/capabilities.ts b/extension/src/capabilities.ts index 32f5831fd63..f1441c1e16a 100644 --- a/extension/src/capabilities.ts +++ b/extension/src/capabilities.ts @@ -5,6 +5,7 @@ export type Capability = | 'prompting' // Support using VS Code to capture user input instead of CLI | 'baseline.v1' | 'secret-prompts.v1' + | 'file-pickers.v1' | 'build-dotnet-using-cli' // Support building .NET projects using the CLI | 'devkit' // Support for .NET DevKit extension (old, used for determining whether to build .NET projects in extension) | 'ms-dotnettools.csdevkit' // Older AppHost versions used this extension identifier instead of devkit @@ -33,7 +34,7 @@ export function isPythonInstalled() { } export function getSupportedCapabilities(): Capabilities { - const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'build-dotnet-using-cli']; + const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'file-pickers.v1', 'build-dotnet-using-cli']; if (isCsDevKitInstalled()) { capabilities.push("devkit"); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 1b02e953ff7..81844681653 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -72,3 +72,5 @@ export const dismissLabel = vscode.l10n.t('Dismiss'); export const openCliInstallInstructions = vscode.l10n.t('See CLI installation instructions'); export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.'); export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path); +export const selectDirectoryTitle = vscode.l10n.t('Select directory'); +export const selectFileTitle = vscode.l10n.t('Select file'); diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts index b3ab6cb3d92..57379e71087 100644 --- a/extension/src/server/interactionService.ts +++ b/extension/src/server/interactionService.ts @@ -2,7 +2,7 @@ import { MessageConnection } from 'vscode-jsonrpc'; import * as vscode from 'vscode'; import * as fs from 'fs/promises'; import { getRelativePathToWorkspace, isFolderOpenInWorkspace } from '../utils/workspace'; -import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces } from '../loc/strings'; +import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces, selectDirectoryTitle, selectFileTitle } from '../loc/strings'; import { ICliRpcClient } from './rpcClient'; import { ProgressNotifier } from './progressNotifier'; import { applyTextStyle, formatText } from '../utils/strings'; @@ -16,6 +16,7 @@ export interface IInteractionService { showStatus: (statusText: string | null) => void; promptForString: (promptText: string, defaultValue: string | null, required: boolean, rpcClient: ICliRpcClient) => Promise; promptForSecretString: (promptText: string, required: boolean, rpcClient: ICliRpcClient) => Promise; + promptForFilePath: (promptText: string, defaultValue: string | null, directory: boolean) => Promise; confirm: (promptText: string, defaultValue: boolean) => Promise; promptForSelection: (promptText: string, choices: string[]) => Promise; promptForSelections: (promptText: string, choices: string[]) => Promise; @@ -169,6 +170,28 @@ export class InteractionService implements IInteractionService { return input ?? null; } + async promptForFilePath(promptText: string, defaultValue: string | null, directory: boolean): Promise { + extensionLogOutputChannel.info(`Prompting for file path: ${promptText}, directory: ${directory}, default: ${defaultValue ?? 'null'}`); + + const defaultUri = defaultValue ? vscode.Uri.file(defaultValue) : undefined; + const openLabel = directory ? selectDirectoryTitle : selectFileTitle; + + const result = await vscode.window.showOpenDialog({ + canSelectFiles: !directory, + canSelectFolders: directory, + canSelectMany: false, + defaultUri, + openLabel, + title: formatText(promptText), + }); + + if (!result || result.length === 0) { + return null; + } + + return result[0].fsPath; + } + async confirm(promptText: string, defaultValue: boolean): Promise { extensionLogOutputChannel.info(`Confirming: ${promptText} with default value: ${defaultValue}`); const yes = yesLabel; @@ -481,6 +504,7 @@ export function addInteractionServiceEndpoints(connection: MessageConnection, in connection.onRequest("showStatus", middleware('showStatus', interactionService.showStatus.bind(interactionService))); connection.onRequest("promptForString", middleware('promptForString', async (promptText: string, defaultValue: string | null, required: boolean) => interactionService.promptForString(promptText, defaultValue, required, rpcClient))); connection.onRequest("promptForSecretString", middleware('promptForSecretString', async (promptText: string, required: boolean) => interactionService.promptForSecretString(promptText, required, rpcClient))); + connection.onRequest("promptForFilePath", middleware('promptForFilePath', async (promptText: string, defaultValue: string | null, directory: boolean) => interactionService.promptForFilePath(promptText, defaultValue, directory))); connection.onRequest("confirm", middleware('confirm', interactionService.confirm.bind(interactionService))); connection.onRequest("promptForSelection", middleware('promptForSelection', interactionService.promptForSelection.bind(interactionService))); connection.onRequest("promptForSelections", middleware('promptForSelections', interactionService.promptForSelections.bind(interactionService))); diff --git a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs index eefff9a8b0f..d98fad345d7 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs @@ -36,6 +36,7 @@ internal interface IExtensionBackchannel Task ConfirmAsync(string promptText, bool defaultValue, CancellationToken cancellationToken); Task PromptForStringAsync(string promptText, string? defaultValue, Func? validator, bool required, CancellationToken cancellationToken); Task PromptForSecretStringAsync(string promptText, Func? validator, bool required, CancellationToken cancellationToken); + Task PromptForFilePathAsync(string promptText, string? defaultValue, bool directory, CancellationToken cancellationToken); Task OpenEditorAsync(string path, CancellationToken cancellationToken); Task LogMessageAsync(LogLevel logLevel, string message, CancellationToken cancellationToken); Task GetCapabilitiesAsync(CancellationToken cancellationToken); @@ -543,6 +544,24 @@ public async Task PromptForSecretStringAsync(string promptText, Func PromptForFilePathAsync(string promptText, string? defaultValue, bool directory, CancellationToken cancellationToken) + { + await ConnectAsync(cancellationToken); + + using var activity = _activitySource.StartActivity(); + + var rpc = await _rpcTaskCompletionSource.Task; + + _logger.LogDebug("Prompting for file path with text: {PromptText}, default value: {DefaultValue}, directory: {Directory}", promptText, defaultValue, directory); + + var result = await rpc.InvokeWithCancellationAsync( + "promptForFilePath", + [_token, promptText, defaultValue, directory], + cancellationToken); + + return result; + } + public async Task OpenEditorAsync(string path, CancellationToken cancellationToken) { await ConnectAsync(cancellationToken); diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index b4f1f905ce2..ef86b025c0b 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -69,7 +69,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var defaultWorkspaceRoot = gitRoot ?? ExecutionContext.WorkingDirectory; // Prompt the user for the workspace root - var workspaceRootPath = await _interactionService.PromptForStringAsync( + var workspaceRootPath = await _interactionService.PromptForFilePathAsync( McpCommandStrings.InitCommand_WorkspaceRootPrompt, defaultValue: defaultWorkspaceRoot.FullName, validator: path => @@ -86,6 +86,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ValidationResult.Success(); }, + directory: true, cancellationToken: cancellationToken); var workspaceRoot = new DirectoryInfo(workspaceRootPath); diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index e7296794ed2..59f76faa2ed 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -456,9 +456,10 @@ public virtual async Task PromptForOutputPath(string path, CancellationT { // Escape markup characters in the path to prevent Spectre.Console from trying to parse them as markup // when displaying it as the default value in the prompt - return await interactionService.PromptForStringAsync( + return await interactionService.PromptForFilePathAsync( NewCommandStrings.EnterTheOutputPath, defaultValue: path.EscapeMarkup(), + directory: true, cancellationToken: cancellationToken ); } diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index c423415d6ac..c3e9188f252 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -156,6 +156,11 @@ public async Task PromptForStringAsync(string promptText, string? defaul return await _outConsole.PromptAsync(prompt, cancellationToken); } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + { + return PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + } + public async Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { ArgumentNullException.ThrowIfNull(promptText, nameof(promptText)); diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index 270f405e69d..f73d543b21f 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -133,6 +133,58 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } } + public async Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + { + if (_extensionPromptEnabled) + { + var hasFilePickersCapability = await Backchannel.HasCapabilityAsync(KnownCapabilities.FilePickers, _cancellationToken).ConfigureAwait(false); + + if (hasFilePickersCapability) + { + var tcs = new TaskCompletionSource(); + + await _extensionTaskChannel.Writer.WriteAsync(async () => + { + try + { + var result = await Backchannel.PromptForFilePathAsync(promptText.RemoveSpectreFormatting(), defaultValue, directory, _cancellationToken).ConfigureAwait(false); + tcs.SetResult(result); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }, cancellationToken).ConfigureAwait(false); + + var picked = await tcs.Task.ConfigureAwait(false); + + if (picked is null) + { + throw new ExtensionOperationCanceledException(promptText); + } + + if (validator is not null) + { + var validationResult = validator(picked); + + if (!validationResult.Successful) + { + var errorMessage = validationResult.Message ?? "Invalid selection."; + DisplayError(errorMessage); + throw new InvalidOperationException(errorMessage); + } + } + + return picked; + } + + // Fall back to string prompt for older extensions without file picker support + return await PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken).ConfigureAwait(false); + } + + return await _consoleInteractionService.PromptForFilePathAsync(promptText, defaultValue, validator, directory, required, cancellationToken).ConfigureAwait(false); + } + public async Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) { if (_extensionPromptEnabled) diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index a7727ffba66..69f7c1a7bd4 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -12,6 +12,7 @@ internal interface IInteractionService Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false); void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false); Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default); + Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default); public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default); Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 549a9018511..0ce2ffb7a30 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -106,11 +106,11 @@ public async Task UpdateProjectAsync(FileInfo projectFile, interactionService.DisplayEmptyLine(); - var selectedPathForNewNuGetConfigFile = await interactionService.PromptForStringAsync( + var selectedPathForNewNuGetConfigFile = await interactionService.PromptForFilePathAsync( promptText: UpdateCommandStrings.WhichDirectoryNuGetConfigPrompt, defaultValue: recommendedNuGetConfigFileDirectory.EscapeMarkup(), validator: null, - isSecret: false, + directory: true, required: true, cancellationToken: cancellationToken); diff --git a/src/Aspire.Cli/Utils/ExtensionHelper.cs b/src/Aspire.Cli/Utils/ExtensionHelper.cs index a2834eb7838..0ac0170359f 100644 --- a/src/Aspire.Cli/Utils/ExtensionHelper.cs +++ b/src/Aspire.Cli/Utils/ExtensionHelper.cs @@ -34,4 +34,5 @@ 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 FilePickers = "file-pickers.v1"; } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 9685e5a68f6..6bc8bece413 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1341,7 +1341,9 @@ public void DisplaySuccess(string message, bool allowMarkup = false) { } public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } public void DisplayCancellationMessage() { } public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => Task.FromResult(true); - public void DisplaySubtleMessage(string message, bool allowMarkup = false) { } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { } public void DisplayEmptyLine() { } public void DisplayPlainText(string text) { } public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 7a0ca45abde..3e659b2fe27 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -887,6 +887,9 @@ public Task PromptForStringAsync(string promptText, string? defaultValue return Task.FromResult(defaultValue ?? string.Empty); } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { if (_shouldCancel || cancellationToken.IsCancellationRequested) diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 2c228f9f058..80e0d0c5e58 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -958,6 +958,8 @@ public CancellationTrackingInteractionService(IInteractionService innerService) public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) => _innerService.ShowStatus(statusText, action, emoji, allowMarkup); public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) => _innerService.PromptForStringAsync(promptText, defaultValue, validator, isSecret, required, cancellationToken); + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => _innerService.PromptForFilePathAsync(promptText, defaultValue, validator, directory, required, cancellationToken); public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => _innerService.ConfirmAsync(promptText, defaultValue, cancellationToken); public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 78f7b45af99..f95b721dfe8 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -463,6 +463,9 @@ public Task> PromptForSelectionsAsync(string promptText, IEn public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task ConfirmAsync(string prompt, bool defaultAnswer, CancellationToken cancellationToken) => throw new NotImplementedException(); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs index 54fe0cd51a6..0bac2dbca30 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs @@ -41,6 +41,11 @@ public Task PromptForStringAsync(string promptText, string? defaultValue return Task.FromResult(defaultValue ?? string.Empty); } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + { + return PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + } + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { if (!choices.Any()) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs index 9decc14937a..caf5010a7c9 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs @@ -56,6 +56,9 @@ internal sealed class TestExtensionBackchannel : IExtensionBackchannel public TaskCompletionSource? PromptForSecretStringAsyncCalled { get; set; } public Func?, bool, Task>? PromptForSecretStringAsyncCallback { get; set; } + public TaskCompletionSource? PromptForFilePathAsyncCalled { get; set; } + public Func>? PromptForFilePathAsyncCallback { get; set; } + public TaskCompletionSource? OpenEditorAsyncCalled { get; set; } public Func? OpenEditorAsyncCallback { get; set; } @@ -149,6 +152,14 @@ public Task ShowStatusAsync(string? status, CancellationToken cancellationToken) return ShowStatusAsyncCallback?.Invoke(status) ?? Task.CompletedTask; } + public Task PromptForFilePathAsync(string promptText, string? defaultValue, bool directory, CancellationToken cancellationToken) + { + PromptForFilePathAsyncCalled?.SetResult(); + return PromptForFilePathAsyncCallback != null + ? PromptForFilePathAsyncCallback.Invoke(promptText, defaultValue, directory) + : Task.FromResult(defaultValue); + } + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken) where T : notnull { PromptForSelectionAsyncCalled?.SetResult(); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index 349b1025a77..069a27d9d52 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -39,6 +39,11 @@ public Task PromptForStringAsync(string promptText, string? defaultValue return Task.FromResult(defaultValue ?? string.Empty); } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + { + return PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + } + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { if (!choices.Any()) From 7f9396cd625abfd0ebf004978ce9f63faf5a9d33 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 2 Mar 2026 16:53:14 -0500 Subject: [PATCH 05/18] Pass open AppHost to CLI commands via --project flag (#14688) * Pass open AppHost to CLI commands via --project flag When the user has an AppHost file open in the editor or configured in .aspire/settings.json, the extension now passes --project to CLI commands that support it (deploy, publish, add, update). Fixes #11024 * Address review feedback: proper arg escaping, raw tokens, tighter AppHost heuristic * Fix --project to --apphost and update tests for sync getAspireCliExecutablePath - Change CLI flag from --project to --apphost to match current CLI interface - Rename getProjectArgs to getAppHostArgs for consistency - Tests already updated to stub vscode.workspace.getConfiguration instead of resolveCliPath --- .../aspire-global-settings.schema.json | 2 +- extension/schemas/aspire-settings.schema.json | 2 +- extension/src/commands/add.ts | 7 +++-- extension/src/commands/deploy.ts | 7 +++-- extension/src/commands/publish.ts | 7 +++-- extension/src/commands/update.ts | 7 +++-- .../src/editor/AspireEditorCommandProvider.ts | 26 +++++++++++++------ extension/src/extension.ts | 8 +++--- extension/src/utils/AspireTerminalProvider.ts | 15 ++++++++++- extension/src/utils/appHostArgs.ts | 14 ++++++++++ 10 files changed, 72 insertions(+), 23 deletions(-) create mode 100644 extension/src/utils/appHostArgs.ts diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 606b7ebd700..03ccb0aa580 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -258,4 +258,4 @@ } }, "additionalProperties": false -} +} \ No newline at end of file diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index 90cfd3abc95..b15d09198d7 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -262,4 +262,4 @@ } }, "additionalProperties": false -} +} \ No newline at end of file diff --git a/extension/src/commands/add.ts b/extension/src/commands/add.ts index e1e158d7b4b..a80dfccdb03 100644 --- a/extension/src/commands/add.ts +++ b/extension/src/commands/add.ts @@ -1,5 +1,8 @@ +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { getAppHostArgs } from '../utils/appHostArgs'; -export async function addCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('add'); +export async function addCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { + const appHostArgs = await getAppHostArgs(editorCommandProvider); + await terminalProvider.sendAspireCommandToAspireTerminal('add', true, appHostArgs); } diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index 057d419f6ca..1cad80f24cc 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,5 +1,8 @@ +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { getAppHostArgs } from '../utils/appHostArgs'; -export async function deployCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('deploy'); +export async function deployCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { + const appHostArgs = await getAppHostArgs(editorCommandProvider); + await terminalProvider.sendAspireCommandToAspireTerminal('deploy', true, appHostArgs); } diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 276ea03a7a8..3df7b1594f6 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,5 +1,8 @@ +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { getAppHostArgs } from '../utils/appHostArgs'; -export async function publishCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('publish'); +export async function publishCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { + const appHostArgs = await getAppHostArgs(editorCommandProvider); + await terminalProvider.sendAspireCommandToAspireTerminal('publish', true, appHostArgs); } diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index 23e8070920e..f5131303f2a 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -1,5 +1,8 @@ +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { getAppHostArgs } from '../utils/appHostArgs'; -export async function updateCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('update'); +export async function updateCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { + const appHostArgs = await getAppHostArgs(editorCommandProvider); + await terminalProvider.sendAspireCommandToAspireTerminal('update', true, appHostArgs); } diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 0d72b0d92e7..7b51b862300 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -70,7 +70,12 @@ export class AspireEditorCommandProvider implements vscode.Disposable { const fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString()); const lines = fileText.split(/\r?\n/); - return lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk')); + if (lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk'))) { + return true; + } + + const firstNonEmptyLine = lines.find(line => line.trim().length > 0)?.trim(); + return firstNonEmptyLine === 'var builder = DistributedApplication.CreateBuilder(args);'; } private onChangeAppHostPath(newPath: string | null) { @@ -113,15 +118,20 @@ export class AspireEditorCommandProvider implements vscode.Disposable { } } - public async tryExecuteRunAppHost(noDebug: boolean): Promise { - let appHostToRun: string; + /** + * Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available. + */ + public async getAppHostPath(): Promise { if (vscode.window.activeTextEditor && await this.isAppHostCsFile(vscode.window.activeTextEditor.document.uri.fsPath)) { - appHostToRun = vscode.window.activeTextEditor.document.uri.fsPath; + return vscode.window.activeTextEditor.document.uri.fsPath; } - else if (this._workspaceAppHostPath) { - appHostToRun = this._workspaceAppHostPath; - } - else { + + return this._workspaceAppHostPath; + } + + public async tryExecuteRunAppHost(noDebug: boolean): Promise { + const appHostToRun = await this.getAppHostPath(); + if (!appHostToRun) { vscode.window.showErrorMessage(noAppHostInWorkspace); return; } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index de001575696..f2e5fe88783 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -50,12 +50,12 @@ export async function activate(context: vscode.ExtensionContext) { const editorCommandProvider = new AspireEditorCommandProvider(); - const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', terminalProvider, addCommand)); + const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', terminalProvider, (tp) => addCommand(tp, editorCommandProvider))); const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', terminalProvider, newCommand)); const cliInitCommandRegistration = vscode.commands.registerCommand('aspire-vscode.init', () => tryExecuteCommand('aspire-vscode.init', terminalProvider, initCommand)); - const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', terminalProvider, deployCommand)); - const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, publishCommand)); - const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, updateCommand)); + const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', terminalProvider, (tp) => deployCommand(tp, editorCommandProvider))); + const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, (tp) => publishCommand(tp, editorCommandProvider))); + const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp) => updateCommand(tp, editorCommandProvider))); const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, openTerminalCommand)); const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand)); const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand)); diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index 95ed6bf5426..edd482ca16e 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -58,7 +58,7 @@ export class AspireTerminalProvider implements vscode.Disposable { this._dcpServerConnectionInfo = value; } - async sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { + async sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true, additionalArgs?: string[]) { const cliPath = await this.getAspireCliExecutablePath(); // On Windows, use & to execute paths, especially those with special characters @@ -73,6 +73,19 @@ export class AspireTerminalProvider implements vscode.Disposable { command = `${quotedPath} ${subcommand}`; } + if (additionalArgs && additionalArgs.length > 0) { + const quotedArgs = additionalArgs.map(arg => { + if (process.platform === 'win32') { + // On Windows PowerShell, wrap in double quotes and escape inner double quotes + return `"${arg.replace(/"/g, '`"')}"`; + } else { + // On Unix, wrap in single quotes and escape inner single quotes + return `'${arg.replace(/'/g, "'\"'\"'")}'`; + } + }); + command += ' ' + quotedArgs.join(' '); + } + if (this.isCliDebugLoggingEnabled()) { command += ' --debug'; } diff --git a/extension/src/utils/appHostArgs.ts b/extension/src/utils/appHostArgs.ts new file mode 100644 index 00000000000..54fdfe2c04d --- /dev/null +++ b/extension/src/utils/appHostArgs.ts @@ -0,0 +1,14 @@ +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; + +/** + * Returns CLI arguments to pass the resolved AppHost project path via --apphost, + * or undefined if no AppHost is currently available. + */ +export async function getAppHostArgs(editorCommandProvider: AspireEditorCommandProvider): Promise { + const appHostPath = await editorCommandProvider.getAppHostPath(); + if (!appHostPath) { + return undefined; + } + + return ['--apphost', appHostPath]; +} From e807cd518f94e2c9f24443c73c1af47d7ca38189 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 2 Mar 2026 17:26:32 -0500 Subject: [PATCH 06/18] Add Aspire Activity Bar panel with running apphosts and resources tree view (#14848) * Add Aspire Activity Bar panel with running apphosts tree view - Add Aspire sidebar panel in VS Code with running AppHosts tree view - Extend 'aspire ps --format json --resources' to include per-AppHost resource data - Show resources (with state, type, endpoints) as collapsible tree items - Backward-compatible: falls back gracefully if CLI doesn't support --resources - Visibility-based polling (only fetches when panel is visible) - Add SVG icon, localized strings, and unit tests * Add error welcome view, updateSelf command, initial visibility polling, and spawn error handling - Show welcome view with upgrade prompt when CLI is missing or too old - Add 'Update Aspire CLI' command (aspire update --self) - Start polling immediately if tree view is already visible on creation - Handle spawn errors (ENOENT) to prevent polling from getting stuck - Change dashboard icon to link-external for clickable items - Remove unused InfoMessageItem in favor of viewsWelcome-based error display * Add Stop and View Logs context menu actions for AppHosts and Resources - Stop AppHost: right-click context menu runs 'aspire stop --project ' - Stop Resource: right-click context menu runs 'aspire stop --project ' - View Logs: right-click context menu runs 'aspire logs --project --follow' - All commands hidden from command palette (tree-only actions) - Localized all new command titles * Add resource management commands and fix RPC race condition - Add Start, Restart, Stop resource and Stop apphost context menu actions - Add View logs context menu for streaming resource logs - Add Execute command... context menu with quick pick for resource commands - Fix RPC race condition: register interaction service endpoints before connection.listen() to prevent unhandled 'displayEmptyLine' errors - Fix RPC client factory to use lazy debugSessionId reference - Use --apphost flag (primary) instead of --project (legacy alias) - Normalize casing: 'apphost' (lowercase h) in user-facing strings * Remove unused string constants from strings.ts * Add Aspire brand purple and semantic colors to tree view icons - Register custom 'aspire.brandPurple' color (#512BD4 light, #7455DD dark) - Color apphost and resources group icons with Aspire purple - Color resource state icons: green for success, yellow for warning, red for error, purple for other active states * Simplify tree provider, fix resource state icons and conditional commands - Merge duplicate detail item classes into single DetailItem - Extract _runResourceCommand helper for stop/start/restart/logs - Store appHostPid directly on ResourceItem instead of parsing ID string - Remove no-op resourceEndpointLabel wrapper - Fix resource state icons to use exact PascalCase state values from KnownResourceStates - Respect stateStyle overrides for health check warnings/errors on Running resources - Show Start/Stop/Restart context menus only when resource has those commands available - Reduce polling interval from 5s to 3s --- extension/loc/xlf/aspire-vscode.xlf | 39 ++ extension/package.json | 170 +++++++ extension/package.nls.json | 17 +- extension/resources/aspire-activity-bar.svg | 8 + extension/src/commands/update.ts | 4 + extension/src/extension.ts | 41 +- extension/src/loc/strings.ts | 11 + extension/src/server/AspireRpcServer.ts | 11 +- .../src/views/AspireAppHostTreeProvider.ts | 431 ++++++++++++++++++ src/Aspire.Cli/Commands/PsCommand.cs | 63 ++- .../Resources/PsCommandStrings.Designer.cs | 6 + .../Resources/PsCommandStrings.resx | 3 + .../Resources/xlf/PsCommandStrings.cs.xlf | 5 + .../Resources/xlf/PsCommandStrings.de.xlf | 5 + .../Resources/xlf/PsCommandStrings.es.xlf | 5 + .../Resources/xlf/PsCommandStrings.fr.xlf | 5 + .../Resources/xlf/PsCommandStrings.it.xlf | 5 + .../Resources/xlf/PsCommandStrings.ja.xlf | 5 + .../Resources/xlf/PsCommandStrings.ko.xlf | 5 + .../Resources/xlf/PsCommandStrings.pl.xlf | 5 + .../Resources/xlf/PsCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/PsCommandStrings.ru.xlf | 5 + .../Resources/xlf/PsCommandStrings.tr.xlf | 5 + .../xlf/PsCommandStrings.zh-Hans.xlf | 5 + .../xlf/PsCommandStrings.zh-Hant.xlf | 5 + .../Commands/PsCommandTests.cs | 178 ++++++++ 26 files changed, 1027 insertions(+), 20 deletions(-) create mode 100644 extension/resources/aspire-activity-bar.svg create mode 100644 extension/src/views/AspireAppHostTreeProvider.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 09d7dbe9ed0..ea53f379a7f 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -7,6 +7,9 @@ Add an integration + + Aspire + Aspire CLI Version: {0}. @@ -130,6 +133,9 @@ Error: {0} + + Execute resource command + Extension context is not initialized. @@ -214,6 +220,9 @@ No output from msbuild. + + No running Aspire apphosts found. [Run an apphost](command:aspire-vscode.runAppHost) + No watch task found. Please ensure a watch task is defined in your workspace. @@ -229,6 +238,9 @@ Open Aspire terminal + + Open Dashboard + Open codespaces URL @@ -256,21 +268,42 @@ RPC server is not initialized. + + Refresh running apphosts + Required capability: {0}. + + Restart + Run Aspire apphost Run {0} + + Running apphosts + See CLI installation instructions Select the default apphost to launch when starting an Aspire debug session + + Start + + + Stop + + + Stop + + + The Aspire CLI is not installed or does not support this feature. Install or update the Aspire CLI to get started. [Update Aspire CLI](command:aspire-vscode.updateSelf) [Refresh](command:aspire-vscode.refreshRunningAppHosts) + The apphost is not compatible. Consider upgrading the apphost or Aspire CLI. @@ -280,9 +313,15 @@ This field is required. + + Update Aspire CLI + Update integrations + + View logs + Watch {0} ({1}) diff --git a/extension/package.json b/extension/package.json index daeae193be5..287da89acd2 100644 --- a/extension/package.json +++ b/extension/package.json @@ -34,6 +34,35 @@ "main": "./dist/extension.js", "l10n": "./l10n", "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "aspire-panel", + "title": "%viewsContainers.aspirePanel.title%", + "icon": "resources/aspire-activity-bar.svg" + } + ] + }, + "views": { + "aspire-panel": [ + { + "id": "aspire-vscode.runningAppHosts", + "name": "%views.runningAppHosts.name%" + } + ] + }, + "viewsWelcome": [ + { + "view": "aspire-vscode.runningAppHosts", + "contents": "%views.runningAppHosts.welcome%", + "when": "aspire.noRunningAppHosts && !aspire.fetchAppHostsError" + }, + { + "view": "aspire-vscode.runningAppHosts", + "contents": "%views.runningAppHosts.errorWelcome%", + "when": "aspire.fetchAppHostsError" + } + ], "debuggers": [ { "type": "aspire", @@ -108,6 +137,11 @@ "title": "%command.update%", "category": "Aspire" }, + { + "command": "aspire-vscode.updateSelf", + "title": "%command.updateSelf%", + "category": "Aspire" + }, { "command": "aspire-vscode.openTerminal", "title": "%command.openTerminal%", @@ -152,6 +186,54 @@ "title": "%command.debugAppHost%", "category": "Aspire", "icon": "$(debug-all)" + }, + { + "command": "aspire-vscode.refreshRunningAppHosts", + "title": "%command.refreshRunningAppHosts%", + "category": "Aspire", + "icon": "$(refresh)" + }, + { + "command": "aspire-vscode.openDashboard", + "title": "%command.openDashboard%", + "category": "Aspire", + "icon": "$(globe)" + }, + { + "command": "aspire-vscode.stopAppHost", + "title": "%command.stopAppHost%", + "category": "Aspire", + "icon": "$(debug-stop)" + }, + { + "command": "aspire-vscode.stopResource", + "title": "%command.stopResource%", + "category": "Aspire", + "icon": "$(debug-stop)" + }, + { + "command": "aspire-vscode.startResource", + "title": "%command.startResource%", + "category": "Aspire", + "icon": "$(debug-start)" + }, + { + "command": "aspire-vscode.restartResource", + "title": "%command.restartResource%", + "category": "Aspire", + "icon": "$(debug-restart)" + }, + { + "command": "aspire-vscode.viewResourceLogs", + "title": "%command.viewResourceLogs%", + "category": "Aspire", + "icon": "$(output)" + }, + { + "command": "aspire-vscode.executeResourceCommand", + "title": "%command.executeResourceCommand%", + "category": "Aspire", + "icon": "$(terminal)" } ], "jsonValidation": [ @@ -197,9 +279,97 @@ { "command": "aspire-vscode.debugAppHost", "when": "false" + }, + { + "command": "aspire-vscode.refreshRunningAppHosts", + "when": "false" + }, + { + "command": "aspire-vscode.openDashboard", + "when": "false" + }, + { + "command": "aspire-vscode.stopAppHost", + "when": "false" + }, + { + "command": "aspire-vscode.stopResource", + "when": "false" + }, + { + "command": "aspire-vscode.startResource", + "when": "false" + }, + { + "command": "aspire-vscode.restartResource", + "when": "false" + }, + { + "command": "aspire-vscode.viewResourceLogs", + "when": "false" + }, + { + "command": "aspire-vscode.executeResourceCommand", + "when": "false" + } + ], + "view/title": [ + { + "command": "aspire-vscode.refreshRunningAppHosts", + "when": "view == aspire-vscode.runningAppHosts", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "aspire-vscode.openDashboard", + "when": "view == aspire-vscode.runningAppHosts && viewItem == appHost", + "group": "inline" + }, + { + "command": "aspire-vscode.stopAppHost", + "when": "view == aspire-vscode.runningAppHosts && viewItem == appHost", + "group": "2_actions@1" + }, + { + "command": "aspire-vscode.stopResource", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource.*:canStop/", + "group": "2_actions@1" + }, + { + "command": "aspire-vscode.startResource", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource.*:canStart/", + "group": "2_actions@2" + }, + { + "command": "aspire-vscode.restartResource", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource.*:canRestart/", + "group": "2_actions@3" + }, + { + "command": "aspire-vscode.viewResourceLogs", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource/", + "group": "3_logs@1" + }, + { + "command": "aspire-vscode.executeResourceCommand", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource/", + "group": "4_commands@1" } ] }, + "colors": [ + { + "id": "aspire.brandPurple", + "description": "Aspire brand purple color used for tree view icons.", + "defaults": { + "dark": "#7455DD", + "light": "#512BD4", + "highContrast": "#9780E5", + "highContrastLight": "#512BD4" + } + } + ], "configuration": { "title": "Aspire", "properties": { diff --git a/extension/package.nls.json b/extension/package.nls.json index 50e29832bcf..293799ef081 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -10,7 +10,8 @@ "command.init": "Initialize Aspire", "command.publish": "Publish deployment artifacts", "command.update": "Update integrations", - "command.openTerminal": "Open Aspire terminal", + "command.updateSelf": "Update Aspire CLI", + "command.openTerminal": "Open Aspire terminal", "command.deploy": "Deploy app", "command.configureLaunchJson": "Configure launch.json file", "command.settings": "Extension settings", @@ -97,5 +98,17 @@ "aspire-vscode.strings.openCliInstallInstructions": "See CLI installation instructions", "aspire-vscode.strings.dismissLabel": "Dismiss", "aspire-vscode.strings.selectDirectoryTitle": "Select directory", - "aspire-vscode.strings.selectFileTitle": "Select file" + "aspire-vscode.strings.selectFileTitle": "Select file", + "viewsContainers.aspirePanel.title": "Aspire", + "views.runningAppHosts.name": "Running apphosts", + "views.runningAppHosts.welcome": "No running Aspire apphosts found.\n[Run an apphost](command:aspire-vscode.runAppHost)", + "views.runningAppHosts.errorWelcome": "The Aspire CLI is not installed or does not support this feature. Install or update the Aspire CLI to get started.\n[Update Aspire CLI](command:aspire-vscode.updateSelf)\n[Refresh](command:aspire-vscode.refreshRunningAppHosts)", + "command.refreshRunningAppHosts": "Refresh running apphosts", + "command.openDashboard": "Open Dashboard", + "command.stopAppHost": "Stop", + "command.stopResource": "Stop", + "command.startResource": "Start", + "command.restartResource": "Restart", + "command.viewResourceLogs": "View logs", + "command.executeResourceCommand": "Execute resource command" } diff --git a/extension/resources/aspire-activity-bar.svg b/extension/resources/aspire-activity-bar.svg new file mode 100644 index 00000000000..6df233aec05 --- /dev/null +++ b/extension/resources/aspire-activity-bar.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index f5131303f2a..f9286a99afd 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -6,3 +6,7 @@ export async function updateCommand(terminalProvider: AspireTerminalProvider, ed const appHostArgs = await getAppHostArgs(editorCommandProvider); await terminalProvider.sendAspireCommandToAspireTerminal('update', true, appHostArgs); } + +export async function updateSelfCommand(terminalProvider: AspireTerminalProvider) { + await terminalProvider.sendAspireCommandToAspireTerminal('update --self'); +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index f2e5fe88783..1878b2930a5 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -20,11 +20,12 @@ import { getResourceDebuggerExtensions } from './debugger/debuggerExtensions'; import { AspireTerminalProvider } from './utils/AspireTerminalProvider'; import { MessageConnection } from 'vscode-jsonrpc'; import { openTerminalCommand } from './commands/openTerminal'; -import { updateCommand } from './commands/update'; +import { updateCommand, updateSelfCommand } from './commands/update'; import { settingsCommand } from './commands/settings'; import { openLocalSettingsCommand, openGlobalSettingsCommand } from './commands/openSettings'; import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace } from './utils/workspace'; import { AspireEditorCommandProvider } from './editor/AspireEditorCommandProvider'; +import { AspireAppHostTreeProvider } from './views/AspireAppHostTreeProvider'; let aspireExtensionContext = new AspireExtensionContext(); @@ -38,7 +39,8 @@ export async function activate(context: vscode.ExtensionContext) { const rpcServer = await AspireRpcServer.create( (rpcServerConnectionInfo: RpcServerConnectionInfo, connection: MessageConnection, token: string, debugSessionId: string | null) => { - return new RpcClient(terminalProvider, connection, debugSessionId, () => aspireExtensionContext.getAspireDebugSession(debugSessionId)); + const client: RpcClient = new RpcClient(terminalProvider, connection, debugSessionId, () => aspireExtensionContext.getAspireDebugSession(client.debugSessionId)); + return client; } ); @@ -56,6 +58,7 @@ export async function activate(context: vscode.ExtensionContext) { const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', terminalProvider, (tp) => deployCommand(tp, editorCommandProvider))); const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, (tp) => publishCommand(tp, editorCommandProvider))); const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp) => updateCommand(tp, editorCommandProvider))); + const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', () => tryExecuteCommand('aspire-vscode.updateSelf', terminalProvider, updateSelfCommand)); const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, openTerminalCommand)); const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand)); const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand)); @@ -64,8 +67,40 @@ export async function activate(context: vscode.ExtensionContext) { const runAppHostCommandRegistration = vscode.commands.registerCommand('aspire-vscode.runAppHost', () => editorCommandProvider.tryExecuteRunAppHost(true)); const debugAppHostCommandRegistration = vscode.commands.registerCommand('aspire-vscode.debugAppHost', () => editorCommandProvider.tryExecuteRunAppHost(false)); + // Aspire panel - running app hosts tree view + const appHostTreeProvider = new AspireAppHostTreeProvider(terminalProvider); + const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', { + treeDataProvider: appHostTreeProvider, + }); + const refreshRunningAppHostsRegistration = vscode.commands.registerCommand('aspire-vscode.refreshRunningAppHosts', () => appHostTreeProvider.refresh()); + const openDashboardRegistration = vscode.commands.registerCommand('aspire-vscode.openDashboard', (element) => appHostTreeProvider.openDashboard(element)); + const stopAppHostRegistration = vscode.commands.registerCommand('aspire-vscode.stopAppHost', (element) => appHostTreeProvider.stopAppHost(element)); + const stopResourceRegistration = vscode.commands.registerCommand('aspire-vscode.stopResource', (element) => appHostTreeProvider.stopResource(element)); + const startResourceRegistration = vscode.commands.registerCommand('aspire-vscode.startResource', (element) => appHostTreeProvider.startResource(element)); + const restartResourceRegistration = vscode.commands.registerCommand('aspire-vscode.restartResource', (element) => appHostTreeProvider.restartResource(element)); + const viewResourceLogsRegistration = vscode.commands.registerCommand('aspire-vscode.viewResourceLogs', (element) => appHostTreeProvider.viewResourceLogs(element)); + const executeResourceCommandRegistration = vscode.commands.registerCommand('aspire-vscode.executeResourceCommand', (element) => appHostTreeProvider.executeResourceCommand(element)); + + // Set initial context for welcome view + vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', true); + + // Start polling when the tree view becomes visible, stop when hidden + if (appHostTreeView.visible) { + appHostTreeProvider.startPolling(); + } + + appHostTreeView.onDidChangeVisibility(e => { + if (e.visible) { + appHostTreeProvider.startPolling(); + } else { + appHostTreeProvider.stopPolling(); + } + }); + + context.subscriptions.push(appHostTreeView, refreshRunningAppHostsRegistration, openDashboardRegistration, stopAppHostRegistration, stopResourceRegistration, startResourceRegistration, restartResourceRegistration, viewResourceLogsRegistration, executeResourceCommandRegistration, { dispose: () => appHostTreeProvider.dispose() }); + context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration); - context.subscriptions.push(cliUpdateCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); + context.subscriptions.push(cliUpdateCommandRegistration, cliUpdateSelfCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); const debugConfigProvider = new AspireDebugConfigurationProvider(); context.subscriptions.push( diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 81844681653..03821ecdaca 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -48,6 +48,17 @@ export const failedToConfigureLaunchJson = (error: any) => vscode.l10n.t('Failed export const defaultConfigurationName = vscode.l10n.t('Aspire: Launch default apphost'); export const debugSessionAlreadyExists = (id: string) => vscode.l10n.t('A debug session is already active for id {0}.', id); export const processExceptionOccurred = (error: string, command: string) => vscode.l10n.t('Encountered an exception ({0}) while running the following command: {1}.', error, command); + +// Aspire panel strings +export const pidDescription = (pid: number) => vscode.l10n.t('PID: {0}', pid); +export const dashboardLabel = vscode.l10n.t('Dashboard'); +export const cliPidLabel = (pid: number) => vscode.l10n.t('CLI PID: {0}', pid); +export const appHostPidLabel = (pid: number) => vscode.l10n.t('Apphost PID: {0}', pid); +export const errorFetchingAppHosts = (error: string) => vscode.l10n.t('Error fetching running apphosts: {0}', error); +export const resourcesGroupLabel = vscode.l10n.t('Resources'); +export const resourceStateLabel = (name: string, state: string) => vscode.l10n.t('{0} — {1}', name, state); +export const noCommandsAvailable = vscode.l10n.t('No commands available for this resource.'); +export const selectCommandPlaceholder = vscode.l10n.t('Select a command to execute'); export const failedToStartDebugSession = vscode.l10n.t('Failed to start debug session.'); export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed to get Aspire config info (exit code: {0}). Try updating the Aspire CLI with: aspire update', exitCode); export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); diff --git a/extension/src/server/AspireRpcServer.ts b/extension/src/server/AspireRpcServer.ts index 8e8d794d75f..8d685dede72 100644 --- a/extension/src/server/AspireRpcServer.ts +++ b/extension/src/server/AspireRpcServer.ts @@ -108,12 +108,17 @@ export default class AspireRpcServer { return 'pong'; })); + // Create the RPC client with a null debug session ID initially. + // Register all interaction service endpoints BEFORE calling listen() + // to avoid a race condition where the CLI sends requests (e.g. displayEmptyLine) + // before handlers are registered. + const rpcClient = rpcClientFactory(connectionInfo, connection, token, null); + addInteractionServiceEndpoints(connection, rpcClient.interactionService, rpcClient, withAuthentication); + connection.listen(); const clientDebugSessionId = await connection.sendRequest('getDebugSessionId'); - - const rpcClient = rpcClientFactory(connectionInfo, connection, token, clientDebugSessionId); - addInteractionServiceEndpoints(connection, rpcClient.interactionService, rpcClient, withAuthentication); + rpcClient.debugSessionId = clientDebugSessionId; rpcServer.addConnection(rpcClient); diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts new file mode 100644 index 00000000000..863656293d4 --- /dev/null +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -0,0 +1,431 @@ +import * as vscode from 'vscode'; +import { spawnCliProcess } from '../debugger/languages/cli'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { + pidDescription, + dashboardLabel, + cliPidLabel, + appHostPidLabel, + errorFetchingAppHosts, + resourcesGroupLabel, + resourceStateLabel, + noCommandsAvailable, + selectCommandPlaceholder, +} from '../loc/strings'; + +interface ResourceUrlJson { + name: string | null; + displayName: string | null; + url: string; + isInternal: boolean; +} + +interface ResourceCommandJson { + description: string | null; +} + +interface ResourceJson { + name: string; + displayName: string | null; + resourceType: string; + state: string | null; + stateStyle: string | null; + healthStatus: string | null; + urls: ResourceUrlJson[] | null; + commands: Record | null; +} + +interface AppHostDisplayInfo { + appHostPath: string; + appHostPid: number; + cliPid: number | null; + dashboardUrl: string | null; + resources: ResourceJson[] | null | undefined; +} + +type TreeElement = AppHostItem | DetailItem | ResourcesGroupItem | ResourceItem; + +class AppHostItem extends vscode.TreeItem { + constructor(public readonly appHost: AppHostDisplayInfo) { + const name = shortenPath(appHost.appHostPath); + super(name, vscode.TreeItemCollapsibleState.Expanded); + this.id = `apphost:${appHost.appHostPid}`; + this.description = pidDescription(appHost.appHostPid); + this.iconPath = new vscode.ThemeIcon('server-process', new vscode.ThemeColor('aspire.brandPurple')); + this.contextValue = 'appHost'; + this.tooltip = appHost.appHostPath; + } +} + +class DetailItem extends vscode.TreeItem { + constructor(label: string, icon: string, tooltip?: string, command?: vscode.Command) { + super(label, vscode.TreeItemCollapsibleState.None); + this.iconPath = new vscode.ThemeIcon(icon); + this.tooltip = tooltip; + this.command = command; + } +} + +class ResourcesGroupItem extends vscode.TreeItem { + constructor(public readonly resources: ResourceJson[], public readonly appHostPid: number) { + super(resourcesGroupLabel, vscode.TreeItemCollapsibleState.Collapsed); + this.id = `resources:${appHostPid}`; + this.iconPath = new vscode.ThemeIcon('layers', new vscode.ThemeColor('aspire.brandPurple')); + this.contextValue = 'resourcesGroup'; + this.description = `(${resources.length})`; + } +} + +class ResourceItem extends vscode.TreeItem { + constructor(public readonly resource: ResourceJson, public readonly appHostPid: number) { + const state = resource.state ?? ''; + const label = state ? resourceStateLabel(resource.displayName ?? resource.name, state) : (resource.displayName ?? resource.name); + const hasUrls = resource.urls && resource.urls.filter(u => !u.isInternal).length > 0; + super(label, hasUrls ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None); + this.id = `resource:${appHostPid}:${resource.name}`; + this.iconPath = getResourceIcon(resource); + this.tooltip = `${resource.resourceType}: ${resource.name}`; + this.contextValue = getResourceContextValue(resource); + } +} + +function getResourceContextValue(resource: ResourceJson): string { + const commands = resource.commands ? Object.keys(resource.commands) : []; + const parts = ['resource']; + if (commands.includes('resource-start')) { + parts.push('canStart'); + } + if (commands.includes('resource-stop')) { + parts.push('canStop'); + } + if (commands.includes('resource-restart')) { + parts.push('canRestart'); + } + return parts.join(':'); +} + +function getResourceIcon(resource: ResourceJson): vscode.ThemeIcon { + const state = resource.state; + switch (state) { + case 'Running': + case 'Active': + if (resource.stateStyle === 'warning') { + return new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground')); + } + if (resource.stateStyle === 'error') { + return new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); + } + return new vscode.ThemeIcon('pass', new vscode.ThemeColor('testing.iconPassed')); + case 'Finished': + case 'Exited': + if (resource.stateStyle === 'error') { + return new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); + } + return new vscode.ThemeIcon('circle-outline'); + case 'FailedToStart': + case 'RuntimeUnhealthy': + return new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); + case 'Starting': + case 'Stopping': + case 'Building': + case 'Waiting': + case 'NotStarted': + return new vscode.ThemeIcon('loading~spin'); + default: + if (state === null || state === undefined) { + return new vscode.ThemeIcon('circle-outline'); + } + return new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('aspire.brandPurple')); + } +} + +export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private _appHosts: AppHostDisplayInfo[] = []; + private _pollingInterval: ReturnType | undefined; + private _disposed = false; + private _supportsResources = true; + private _fetchInProgress = false; + private _errorMessage: string | undefined; + + constructor(private readonly _terminalProvider: AspireTerminalProvider) {} + + refresh(): void { + this._fetchAppHosts(); + } + + startPolling(intervalMs: number = 3000): void { + this.stopPolling(); + // Fetch immediately, then poll + this._fetchAppHosts(); + this._pollingInterval = setInterval(() => { + if (!this._disposed) { + this._fetchAppHosts(); + } + }, intervalMs); + } + + stopPolling(): void { + if (this._pollingInterval) { + clearInterval(this._pollingInterval); + this._pollingInterval = undefined; + } + } + + dispose(): void { + this._disposed = true; + this.stopPolling(); + this._onDidChangeTreeData.dispose(); + } + + getTreeItem(element: TreeElement): vscode.TreeItem { + return element; + } + + getChildren(element?: TreeElement): TreeElement[] { + if (!element) { + return this._appHosts.map(appHost => new AppHostItem(appHost)); + } + + if (element instanceof AppHostItem) { + const items: (DetailItem | ResourcesGroupItem)[] = []; + const appHost = element.appHost; + + if (appHost.dashboardUrl) { + items.push(new DetailItem( + dashboardLabel, + 'link-external', + appHost.dashboardUrl, + { + command: 'vscode.open', + title: dashboardLabel, + arguments: [vscode.Uri.parse(appHost.dashboardUrl)] + } + )); + } + + items.push(new DetailItem( + appHostPidLabel(appHost.appHostPid), + 'terminal', + )); + + if (appHost.cliPid !== null) { + items.push(new DetailItem( + cliPidLabel(appHost.cliPid), + 'terminal-cmd', + )); + } + + // Show resources group if available (backward-compatible: older CLIs won't have this field) + if (appHost.resources && appHost.resources.length > 0) { + items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid)); + } + + return items; + } + + if (element instanceof ResourcesGroupItem) { + return element.resources.map(r => new ResourceItem(r, element.appHostPid)); + } + + if (element instanceof ResourceItem) { + const urls = element.resource.urls?.filter(u => !u.isInternal) ?? []; + return urls.map(url => new DetailItem( + url.displayName ?? url.url, + 'link-external', + url.url, + { + command: 'vscode.open', + title: url.url, + arguments: [vscode.Uri.parse(url.url)] + } + )); + } + + return []; + } + + openDashboard(element?: TreeElement): void { + let url: string | null = null; + + if (element instanceof AppHostItem) { + url = element.appHost.dashboardUrl; + } + + if (!url && this._appHosts.length > 0) { + url = this._appHosts[0].dashboardUrl; + } + + if (url) { + vscode.env.openExternal(vscode.Uri.parse(url)); + } + } + + stopAppHost(element: AppHostItem): void { + this._terminalProvider.sendAspireCommandToAspireTerminal(`stop --apphost "${element.appHost.appHostPath}"`); + } + + stopResource(element: ResourceItem): void { + this._runResourceCommand(element, 'stop'); + } + + startResource(element: ResourceItem): void { + this._runResourceCommand(element, 'start'); + } + + restartResource(element: ResourceItem): void { + this._runResourceCommand(element, 'restart'); + } + + viewResourceLogs(element: ResourceItem): void { + this._runResourceCommand(element, 'logs', '--follow'); + } + + async executeResourceCommand(element: ResourceItem): Promise { + const appHost = this._findAppHostForResource(element); + if (!appHost) { + return; + } + + const commands = element.resource.commands; + if (!commands || Object.keys(commands).length === 0) { + vscode.window.showInformationMessage(noCommandsAvailable); + return; + } + + const items = Object.entries(commands).map(([name, cmd]) => ({ + label: name, + description: cmd.description ?? undefined, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: selectCommandPlaceholder, + }); + + if (selected) { + this._terminalProvider.sendAspireCommandToAspireTerminal(`command "${element.resource.name}" "${selected.label}" --apphost "${appHost.appHostPath}"`); + } + } + + private _runResourceCommand(element: ResourceItem, command: string, ...extraArgs: string[]): void { + const appHost = this._findAppHostForResource(element); + if (!appHost) { + return; + } + const suffix = extraArgs.length > 0 ? ` ${extraArgs.join(' ')}` : ''; + this._terminalProvider.sendAspireCommandToAspireTerminal(`${command} "${element.resource.name}" --apphost "${appHost.appHostPath}"${suffix}`); + } + + private _findAppHostForResource(element: ResourceItem): AppHostDisplayInfo | undefined { + return this._appHosts.find(a => a.appHostPid === element.appHostPid); + } + + private _fetchAppHosts(): void { + if (this._fetchInProgress) { + return; + } + this._fetchInProgress = true; + + const args = ['ps', '--format', 'json']; + if (this._supportsResources) { + args.push('--resources'); + } + this._runPsCommand(args, (code, stdout, stderr) => { + if (code === 0) { + this._setError(undefined); + this._handlePsOutput(stdout); + this._fetchInProgress = false; + } else if (this._supportsResources) { + // The --resources flag may not be supported by this CLI version; retry without it + this._supportsResources = false; + extensionLogOutputChannel.info('aspire ps --resources failed, falling back to aspire ps without --resources'); + this._runPsCommand(['ps', '--format', 'json'], (retryCode, retryStdout, retryStderr) => { + if (retryCode === 0) { + this._setError(undefined); + this._handlePsOutput(retryStdout); + } else { + this._setError(errorFetchingAppHosts(retryStderr || `exit code ${retryCode}`)); + } + this._fetchInProgress = false; + }); + } else { + this._setError(errorFetchingAppHosts(stderr || `exit code ${code}`)); + this._fetchInProgress = false; + } + }); + } + + private _setError(message: string | undefined): void { + const hasError = message !== undefined; + if (this._errorMessage !== message) { + this._errorMessage = message; + if (message) { + extensionLogOutputChannel.warn(message); + } + vscode.commands.executeCommand('setContext', 'aspire.fetchAppHostsError', hasError); + this._onDidChangeTreeData.fire(); + } + } + + private _handlePsOutput(stdout: string): void { + try { + const parsed: AppHostDisplayInfo[] = JSON.parse(stdout); + const changed = JSON.stringify(parsed) !== JSON.stringify(this._appHosts); + this._appHosts = parsed; + + if (changed) { + vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', parsed.length === 0); + this._onDidChangeTreeData.fire(); + } + } catch (e) { + extensionLogOutputChannel.warn(`Failed to parse aspire ps output: ${e}`); + } + } + + private async _runPsCommand(args: string[], callback: (code: number, stdout: string, stderr: string) => void): Promise { + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + + let stdout = ''; + let stderr = ''; + let callbackInvoked = false; + + spawnCliProcess(this._terminalProvider, cliPath, args, { + noExtensionVariables: true, + stdoutCallback: (data) => { stdout += data; }, + stderrCallback: (data) => { stderr += data; }, + exitCallback: (code) => { + if (!callbackInvoked) { + callbackInvoked = true; + callback(code ?? 1, stdout, stderr); + } + }, + errorCallback: (error) => { + extensionLogOutputChannel.warn(errorFetchingAppHosts(error.message)); + // Spawn errors (e.g. CLI not installed) may not fire 'close', so invoke callback to unblock polling + if (!callbackInvoked) { + callbackInvoked = true; + callback(1, stdout, stderr || error.message); + } + } + }); + } +} + +function shortenPath(filePath: string): string { + const fileName = filePath.split(/[/\\]/).pop() ?? filePath; + + if (fileName.endsWith('.csproj')) { + return fileName; + } + + // For single-file AppHosts (.cs), show parent/filename + const parts = filePath.split(/[/\\]/); + if (parts.length >= 2) { + return `${parts[parts.length - 2]}/${fileName}`; + } + + return fileName; +} diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index a30b3760069..252bf0624e7 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Aspire.Shared.Model.Serialization; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -20,13 +21,27 @@ namespace Aspire.Cli.Commands; /// Represents information about a running AppHost for JSON serialization. /// Aligned with AppHostListInfo from ListAppHostsTool. /// -internal sealed record AppHostDisplayInfo( - string AppHostPath, - int AppHostPid, - int? CliPid, - string? DashboardUrl); +internal sealed class AppHostDisplayInfo +{ + public required string AppHostPath { get; init; } + public required int AppHostPid { get; init; } + public int? CliPid { get; init; } + public string? DashboardUrl { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Resources { get; set; } +} [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ResourceJson))] +[JsonSerializable(typeof(ResourceUrlJson))] +[JsonSerializable(typeof(ResourceVolumeJson))] +[JsonSerializable(typeof(ResourceRelationshipJson))] +[JsonSerializable(typeof(ResourceHealthReportJson))] +[JsonSerializable(typeof(ResourceCommandJson))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal sealed partial class PsCommandJsonContext : JsonSerializerContext { @@ -55,6 +70,11 @@ internal sealed class PsCommand : BaseCommand Description = PsCommandStrings.JsonOptionDescription }; + private static readonly Option s_resourcesOption = new("--resources") + { + Description = PsCommandStrings.ResourcesOptionDescription + }; + public PsCommand( IInteractionService interactionService, IAuxiliaryBackchannelMonitor backchannelMonitor, @@ -70,6 +90,7 @@ public PsCommand( _logger = logger; Options.Add(s_formatOption); + Options.Add(s_resourcesOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -77,6 +98,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell using var activity = Telemetry.StartDiagnosticActivity(Name); var format = parseResult.GetValue(s_formatOption); + var includeResources = parseResult.GetValue(s_resourcesOption); // Scan for running AppHosts (same as ListAppHostsTool) // Skip status display for JSON output to avoid contaminating stdout @@ -108,7 +130,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell .ToList(); // Gather info for each AppHost - var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, cancellationToken).ConfigureAwait(false); + var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, includeResources && format == OutputFormat.Json, cancellationToken).ConfigureAwait(false); if (format == OutputFormat.Json) { @@ -124,7 +146,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.Success; } - private async Task> GatherAppHostInfosAsync(List connections, CancellationToken cancellationToken) + private async Task> GatherAppHostInfosAsync(List connections, bool includeResources, CancellationToken cancellationToken) { var appHostInfos = new List(); @@ -148,11 +170,28 @@ private async Task> GatherAppHostInfosAsync(List? resources = null; + if (includeResources) + { + try + { + var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardUrl, includeEnvironmentVariableValues: false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to get resource snapshots for {AppHostPath}", info.AppHostPath); + } + } + + appHostInfos.Add(new AppHostDisplayInfo + { + AppHostPath = info.AppHostPath ?? PsCommandStrings.UnknownPath, + AppHostPid = info.ProcessId, + CliPid = info.CliProcessId, + DashboardUrl = dashboardUrl, + Resources = resources + }); } return appHostInfos; diff --git a/src/Aspire.Cli/Resources/PsCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/PsCommandStrings.Designer.cs index cdcae4fb2c9..8a393d659f7 100644 --- a/src/Aspire.Cli/Resources/PsCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/PsCommandStrings.Designer.cs @@ -86,5 +86,11 @@ public static string UnknownPath { return ResourceManager.GetString("UnknownPath", resourceCulture); } } + + public static string ResourcesOptionDescription { + get { + return ResourceManager.GetString("ResourcesOptionDescription", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/PsCommandStrings.resx b/src/Aspire.Cli/Resources/PsCommandStrings.resx index 0543df4272d..3a5912e126f 100644 --- a/src/Aspire.Cli/Resources/PsCommandStrings.resx +++ b/src/Aspire.Cli/Resources/PsCommandStrings.resx @@ -138,4 +138,7 @@ Unknown + + Include resource details for each running app host. Only applies to JSON output. + diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf index 618f4446bc0..39c34d4d693 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf @@ -32,6 +32,11 @@ Výstup ve formátu JSON + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Neznámé diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf index 37ce8446cdb..534d1b9d94a 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf @@ -32,6 +32,11 @@ Ausgabe im JSON-Format. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Unbekannt diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf index aaf1b592dc6..003d06b0cd0 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf @@ -32,6 +32,11 @@ Salida en formato JSON. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Desconocido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf index 16a0e7d7219..3160adcc49a 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf @@ -32,6 +32,11 @@ Sortie au format JSON. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Inconnu diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf index 244791d9954..d7be12356f4 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf @@ -32,6 +32,11 @@ Output in formato JSON. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Sconosciuto diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf index 7bdcc4da60a..a2925182c55 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf @@ -32,6 +32,11 @@ JSON 形式で出力します。 + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown 不明 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf index f4b59755a29..65022c77332 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf @@ -32,6 +32,11 @@ JSON 형식의 출력입니다. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown 알 수 없음 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf index e967f0fe69c..79db808550c 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf @@ -32,6 +32,11 @@ Wynik w formacie JSON. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Nieznane diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf index 39bd41a5f9a..4945980cd1e 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf @@ -32,6 +32,11 @@ Saída no formato JSON. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Desconhecido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf index 25b294f0577..f51aea77cca 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf @@ -32,6 +32,11 @@ Вывод в формате JSON. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Неизвестно diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf index 524259e8b83..b833073e1d9 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf @@ -32,6 +32,11 @@ Çıkışı JSON biçiminde oluşturun. + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown Bilinmiyor diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf index 308ad4f479f..53eb64872fa 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf @@ -32,6 +32,11 @@ 以 JSON 格式输出。 + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown 未知 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf index 563e4ee0894..6f85e14c536 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf @@ -32,6 +32,11 @@ 以 JSON 格式輸出。 + + Include resource details for each running app host. Only applies to JSON output. + Include resource details for each running app host. Only applies to JSON output. + + Unknown 未知 diff --git a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs index 492d78de954..a981b6a8ff6 100644 --- a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs @@ -189,4 +189,182 @@ public async Task PsCommand_JsonFormat_NoResults_WritesEmptyArrayToStdout() Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind); Assert.Equal(0, document.RootElement.GetArrayLength()); } + + [Fact] + public async Task PsCommand_ResourcesOption_IncludesResourcesInJsonOutput() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), + ProcessId = 1234, + CliProcessId = 5678 + }, + DashboardUrlsState = new DashboardUrlsState + { + BaseUrlWithLoginToken = "http://localhost:18888/login?t=abc123" + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "apiservice", + DisplayName = "apiservice", + ResourceType = "Project", + State = "Running", + StateStyle = "success", + Urls = + [ + new ResourceSnapshotUrl { Name = "https", Url = "https://localhost:7001" } + ] + }, + new ResourceSnapshot + { + Name = "redis", + DisplayName = "redis", + ResourceType = "Container", + State = "Running", + StateStyle = "success" + } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = textWriter; + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ps --format json --resources"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = string.Join(string.Empty, textWriter.Logs); + var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); + Assert.NotNull(appHosts); + Assert.Single(appHosts); + + var appHost = appHosts[0]; + Assert.NotNull(appHost.Resources); + Assert.Equal(2, appHost.Resources.Count); + + var apiService = appHost.Resources.First(r => r.Name == "apiservice"); + Assert.Equal("Project", apiService.ResourceType); + Assert.Equal("Running", apiService.State); + Assert.NotNull(apiService.Urls); + Assert.Single(apiService.Urls); + Assert.Equal("https://localhost:7001", apiService.Urls[0].Url); + + var redis = appHost.Resources.First(r => r.Name == "redis"); + Assert.Equal("Container", redis.ResourceType); + Assert.Equal("Running", redis.State); + } + + [Fact] + public async Task PsCommand_WithoutResourcesOption_OmitsResourcesFromJsonOutput() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), + ProcessId = 1234 + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "apiservice", + ResourceType = "Project", + State = "Running" + } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = textWriter; + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ps --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = string.Join(string.Empty, textWriter.Logs); + var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); + Assert.NotNull(appHosts); + Assert.Single(appHosts); + Assert.Null(appHosts[0].Resources); + + // Also verify the raw JSON doesn't contain a "resources" key + var document = JsonDocument.Parse(jsonOutput); + var firstElement = document.RootElement[0]; + Assert.False(firstElement.TryGetProperty("resources", out _)); + } + + [Fact] + public async Task PsCommand_ResourcesOption_TableFormat_DoesNotFetchResources() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + + var resourcesFetched = false; + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), + ProcessId = 1234 + }, + GetResourceSnapshotsHandler = _ => + { + resourcesFetched = true; + return Task.FromResult(new List + { + new ResourceSnapshot { Name = "apiservice", ResourceType = "Project", State = "Running" } + }); + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = textWriter; + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + // --resources with table format should not fetch resources + var result = command.Parse("ps --resources"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.False(resourcesFetched, "Resources should not be fetched when output format is table"); + } } From 3f861640b2ea2d2eec80288133005df8231b3ba5 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 3 Mar 2026 06:30:34 +0800 Subject: [PATCH 07/18] Add more status indicators while work is in-progress (#14834) * Display status in more places in CLI * Update * Update * Update * Update * Add status to templates * Fix emoji --- src/Aspire.Cli/Commands/AddCommand.cs | 10 +- src/Aspire.Cli/Commands/InitCommand.cs | 4 +- src/Aspire.Cli/Commands/NewCommand.cs | 100 +++++++++++------- src/Aspire.Cli/Commands/RunCommand.cs | 4 +- src/Aspire.Cli/Commands/UpdateCommand.cs | 16 ++- src/Aspire.Cli/Interaction/KnownEmojis.cs | 1 + .../Projects/GuestAppHostProject.cs | 11 +- src/Aspire.Cli/Projects/ProjectUpdater.cs | 17 ++- .../Resources/AddCommandStrings.Designer.cs | 8 ++ .../Resources/AddCommandStrings.resx | 3 + .../Resources/InitCommandStrings.Designer.cs | 6 ++ .../Resources/InitCommandStrings.resx | 3 + .../Resources/NewCommandStrings.Designer.cs | 6 ++ .../Resources/NewCommandStrings.resx | 3 + .../UpdateCommandStrings.Designer.cs | 6 ++ .../Resources/UpdateCommandStrings.resx | 18 ++++ .../Resources/xlf/AddCommandStrings.cs.xlf | 5 + .../Resources/xlf/AddCommandStrings.de.xlf | 5 + .../Resources/xlf/AddCommandStrings.es.xlf | 5 + .../Resources/xlf/AddCommandStrings.fr.xlf | 5 + .../Resources/xlf/AddCommandStrings.it.xlf | 5 + .../Resources/xlf/AddCommandStrings.ja.xlf | 5 + .../Resources/xlf/AddCommandStrings.ko.xlf | 5 + .../Resources/xlf/AddCommandStrings.pl.xlf | 5 + .../Resources/xlf/AddCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/AddCommandStrings.ru.xlf | 5 + .../Resources/xlf/AddCommandStrings.tr.xlf | 5 + .../xlf/AddCommandStrings.zh-Hans.xlf | 5 + .../xlf/AddCommandStrings.zh-Hant.xlf | 5 + .../Resources/xlf/InitCommandStrings.cs.xlf | 5 + .../Resources/xlf/InitCommandStrings.de.xlf | 5 + .../Resources/xlf/InitCommandStrings.es.xlf | 5 + .../Resources/xlf/InitCommandStrings.fr.xlf | 5 + .../Resources/xlf/InitCommandStrings.it.xlf | 5 + .../Resources/xlf/InitCommandStrings.ja.xlf | 5 + .../Resources/xlf/InitCommandStrings.ko.xlf | 5 + .../Resources/xlf/InitCommandStrings.pl.xlf | 5 + .../xlf/InitCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/InitCommandStrings.ru.xlf | 5 + .../Resources/xlf/InitCommandStrings.tr.xlf | 5 + .../xlf/InitCommandStrings.zh-Hans.xlf | 5 + .../xlf/InitCommandStrings.zh-Hant.xlf | 5 + .../Resources/xlf/NewCommandStrings.cs.xlf | 5 + .../Resources/xlf/NewCommandStrings.de.xlf | 5 + .../Resources/xlf/NewCommandStrings.es.xlf | 5 + .../Resources/xlf/NewCommandStrings.fr.xlf | 5 + .../Resources/xlf/NewCommandStrings.it.xlf | 5 + .../Resources/xlf/NewCommandStrings.ja.xlf | 5 + .../Resources/xlf/NewCommandStrings.ko.xlf | 5 + .../Resources/xlf/NewCommandStrings.pl.xlf | 5 + .../Resources/xlf/NewCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/NewCommandStrings.ru.xlf | 5 + .../Resources/xlf/NewCommandStrings.tr.xlf | 5 + .../xlf/NewCommandStrings.zh-Hans.xlf | 5 + .../xlf/NewCommandStrings.zh-Hant.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.cs.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.de.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.es.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.fr.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.it.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.ja.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.ko.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.pl.xlf | 30 ++++++ .../xlf/UpdateCommandStrings.pt-BR.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.ru.xlf | 30 ++++++ .../Resources/xlf/UpdateCommandStrings.tr.xlf | 30 ++++++ .../xlf/UpdateCommandStrings.zh-Hans.xlf | 30 ++++++ .../xlf/UpdateCommandStrings.zh-Hant.xlf | 30 ++++++ .../CliTemplateFactory.EmptyTemplate.cs | 48 ++++++--- ...mplateFactory.TypeScriptStarterTemplate.cs | 67 +++++++----- .../Templating/DotNetTemplateFactory.cs | 4 +- .../Commands/NewCommandTests.cs | 64 +++++++++++ 72 files changed, 883 insertions(+), 101 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index ef7bb20a7bb..cd8bac92719 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -212,10 +212,12 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => // which prevents 'dotnet add package' from modifying the project. if (_features.IsFeatureEnabled(KnownFeatures.RunningInstanceDetectionEnabled, defaultValue: true)) { - var runningInstanceResult = await project.FindAndStopRunningInstanceAsync( - effectiveAppHostProjectFile, - ExecutionContext.HomeDirectory, - cancellationToken); + var runningInstanceResult = await InteractionService.ShowStatusAsync( + AddCommandStrings.CheckingForRunningInstances, + async () => await project.FindAndStopRunningInstanceAsync( + effectiveAppHostProjectFile, + ExecutionContext.HomeDirectory, + cancellationToken)); if (runningInstanceResult == RunningInstanceResult.InstanceStopped) { diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 709ea77aca5..614b7fb7653 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -678,7 +678,9 @@ private static bool IsSupportedTfm(string tfm) private async Task<(NuGetPackage Package, PackageChannel Channel)> GetProjectTemplatesVersionAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var allChannels = await _packagingService.GetChannelsAsync(cancellationToken); + var allChannels = await InteractionService.ShowStatusAsync( + InitCommandStrings.ResolvingTemplateVersion, + async () => await _packagingService.GetChannelsAsync(cancellationToken)); // Check if --channel option was provided (highest priority) var channelName = parseResult.GetValue(_channelOption); diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 59f76faa2ed..41b75856014 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -232,50 +233,68 @@ private ITemplate[] GetTemplatesForPrompt(ParseResult parseResult) return null; } - return await _prompter.PromptForTemplateAsync(templatesForPrompt, cancellationToken); - } - - private async Task ResolveCliTemplateVersionAsync(ParseResult parseResult, CancellationToken cancellationToken) - { - var channels = await _packagingService.GetChannelsAsync(cancellationToken); + var result = await _prompter.PromptForTemplateAsync(templatesForPrompt, cancellationToken); - var configuredChannelName = parseResult.GetValue(_channelOption); - if (string.IsNullOrWhiteSpace(configuredChannelName)) + // The prompt is cleared after selection. + // Write out the selected template again for context before proceeding. + if (result != null) { - configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + InteractionService.DisplayPlainText($"{NewCommandStrings.SelectAProjectTemplate} {result.Description}"); } + return result; + } - var selectedChannel = string.IsNullOrWhiteSpace(configuredChannelName) - ? channels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) ?? channels.FirstOrDefault() - : channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)); - - if (selectedChannel is null) - { - if (string.IsNullOrWhiteSpace(configuredChannelName)) - { - InteractionService.DisplayError("No package channels are available."); - } - else - { - InteractionService.DisplayError($"No channel found matching '{configuredChannelName}'. Valid options are: {string.Join(", ", channels.Select(c => c.Name))}"); - } - - return null; - } + private sealed class ResolveTemplateVersionResult + { + public string? Version { get; init; } - var packages = await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken); - var package = packages - .Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _)) - .OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer) - .FirstOrDefault(); + [MemberNotNullWhen(true, nameof(Version))] + [MemberNotNullWhen(false, nameof(ErrorMessage))] + public bool Success => Version is not null; - if (package is null) - { - InteractionService.DisplayError($"No template versions found in channel '{selectedChannel.Name}'."); - return null; - } + public string? ErrorMessage { get; init; } + } - return package.Version; + private async Task ResolveCliTemplateVersionAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + return await InteractionService.ShowStatusAsync( + NewCommandStrings.ResolvingTemplateVersion, + async () => + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken); + + var configuredChannelName = parseResult.GetValue(_channelOption); + if (string.IsNullOrWhiteSpace(configuredChannelName)) + { + configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + } + + var selectedChannel = string.IsNullOrWhiteSpace(configuredChannelName) + ? channels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) ?? channels.FirstOrDefault() + : channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)); + + if (selectedChannel is null) + { + var errorMessage = string.IsNullOrWhiteSpace(configuredChannelName) + ? "No package channels are available." + : $"No channel found matching '{configuredChannelName}'. Valid options are: {string.Join(", ", channels.Select(c => c.Name))}"; + + return new ResolveTemplateVersionResult { ErrorMessage = errorMessage }; + } + + var packages = await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken); + var package = packages + .Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _)) + .OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer) + .FirstOrDefault(); + + if (package is null) + { + return new ResolveTemplateVersionResult { ErrorMessage = $"No template versions found in channel '{selectedChannel.Name}'." }; + } + + return new ResolveTemplateVersionResult { Version = package.Version }; + }); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -298,11 +317,14 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (ShouldResolveCliTemplateVersion(template) && string.IsNullOrWhiteSpace(version)) { - version = await ResolveCliTemplateVersionAsync(parseResult, cancellationToken); - if (string.IsNullOrWhiteSpace(version)) + var resolveResult = await ResolveCliTemplateVersionAsync(parseResult, cancellationToken); + if (!resolveResult.Success) { + InteractionService.DisplayError(resolveResult.ErrorMessage); return ExitCodeConstants.InvalidCommand; } + + version = resolveResult.Version; } var inputs = new TemplateInputs diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 50fab092700..f2e71ee748c 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -206,7 +206,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Even if we fail to stop we won't block the apphost starting // to make sure we don't ever break flow. It should mostly stop // just fine though. - var runningInstanceResult = await project.FindAndStopRunningInstanceAsync(effectiveAppHostFile, ExecutionContext.HomeDirectory, cancellationToken); + var runningInstanceResult = await InteractionService.ShowStatusAsync( + RunCommandStrings.CheckingForRunningInstances, + async () => await project.FindAndStopRunningInstanceAsync(effectiveAppHostFile, ExecutionContext.HomeDirectory, cancellationToken)); // If in isolated mode and a running instance was stopped, warn the user if (isolated && runningInstanceResult == RunningInstanceResult.InstanceStopped) diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 839f6d65a40..5e9f7716959 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -152,7 +152,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var channelName = parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption); PackageChannel channel; - var allChannels = await _packagingService.GetChannelsAsync(cancellationToken); + var allChannels = await InteractionService.ShowStatusAsync( + UpdateCommandStrings.CheckingForUpdates, + async () => await _packagingService.GetChannelsAsync(cancellationToken)); if (!string.IsNullOrEmpty(channelName)) { @@ -347,8 +349,16 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c try { // Extract archive - InteractionService.DisplayMessage(KnownEmojis.Package, "Extracting new CLI..."); - await ArchiveHelper.ExtractAsync(archivePath, tempExtractDir, cancellationToken); + await InteractionService.ShowStatusAsync( + UpdateCommandStrings.ExtractingNewCli, + async () => + { + await ArchiveHelper.ExtractAsync(archivePath, tempExtractDir, cancellationToken); + return 0; + }, + KnownEmojis.Package); + + InteractionService.DisplayMessage(KnownEmojis.Package, UpdateCommandStrings.ExtractedNewCli); // Find the aspire executable in the extracted files var newExePath = Path.Combine(tempExtractDir, exeName); diff --git a/src/Aspire.Cli/Interaction/KnownEmojis.cs b/src/Aspire.Cli/Interaction/KnownEmojis.cs index e8f4fd99c01..e6c05a37f4a 100644 --- a/src/Aspire.Cli/Interaction/KnownEmojis.cs +++ b/src/Aspire.Cli/Interaction/KnownEmojis.cs @@ -29,6 +29,7 @@ internal static class KnownEmojis public static readonly KnownEmoji FloppyDisk = new("floppy_disk"); public static readonly KnownEmoji Gear = new("gear"); public static readonly KnownEmoji Hammer = new("hammer"); + public static readonly KnownEmoji Ice = new("ice"); public static readonly KnownEmoji HammerAndWrench = new("hammer_and_wrench"); public static readonly KnownEmoji Information = new("information"); public static readonly KnownEmoji Key = new("key"); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 2f2cb3666b4..be59f437027 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -964,8 +964,15 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex // Rebuild and regenerate SDK code with updated packages _interactionService.DisplayEmptyLine(); - _interactionService.DisplaySubtleMessage("Regenerating SDK code with updated packages..."); - await BuildAndGenerateSdkAsync(directory, cancellationToken); + await _interactionService.ShowStatusAsync( + UpdateCommandStrings.RegeneratingSdkCode, + async () => + { + await BuildAndGenerateSdkAsync(directory, cancellationToken); + return 0; + }); + + _interactionService.DisplayMessage(KnownEmojis.Package, UpdateCommandStrings.RegeneratedSdkCode); _interactionService.DisplayEmptyLine(); _interactionService.DisplaySuccess(UpdateCommandStrings.UpdateSuccessfulMessage); diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 0ce2ffb7a30..622e41e1d30 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -120,11 +120,18 @@ public async Task UpdateProjectAsync(FileInfo projectFile, interactionService.DisplayEmptyLine(); - foreach (var updateStep in updateSteps) - { - interactionService.DisplaySubtleMessage(string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.ExecutingUpdateStepFormat, updateStep.Description)); - await updateStep.Callback(); - } + await interactionService.ShowStatusAsync( + UpdateCommandStrings.ApplyingUpdates, + async () => + { + foreach (var updateStep in updateSteps) + { + interactionService.DisplaySubtleMessage(string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.ExecutingUpdateStepFormat, updateStep.Description)); + await updateStep.Callback(); + } + + return 0; + }); interactionService.DisplayEmptyLine(); diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs index 9f5e8514b27..71c85806aaa 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs @@ -204,5 +204,13 @@ public static string UnableToStopRunningInstances return ResourceManager.GetString("UnableToStopRunningInstances", resourceCulture); } } + + public static string CheckingForRunningInstances + { + get + { + return ResourceManager.GetString("CheckingForRunningInstances", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.resx b/src/Aspire.Cli/Resources/AddCommandStrings.resx index 84623e973d3..1c6d2d5e594 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AddCommandStrings.resx @@ -178,4 +178,7 @@ Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Checking for running instances... + diff --git a/src/Aspire.Cli/Resources/InitCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/InitCommandStrings.Designer.cs index 8178ece1dc7..9e80ff89783 100644 --- a/src/Aspire.Cli/Resources/InitCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/InitCommandStrings.Designer.cs @@ -110,5 +110,11 @@ internal static string AddingServiceDefaultsProjectToSolution { return ResourceManager.GetString("AddingServiceDefaultsProjectToSolution", resourceCulture); } } + + internal static string ResolvingTemplateVersion { + get { + return ResourceManager.GetString("ResolvingTemplateVersion", resourceCulture); + } + } } } \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/InitCommandStrings.resx b/src/Aspire.Cli/Resources/InitCommandStrings.resx index 23977ff23b0..dc351b5cdea 100644 --- a/src/Aspire.Cli/Resources/InitCommandStrings.resx +++ b/src/Aspire.Cli/Resources/InitCommandStrings.resx @@ -93,4 +93,7 @@ Adding ServiceDefaults project to solution... + + Resolving template version... + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/NewCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/NewCommandStrings.Designer.cs index 198156b9900..6e436d799ce 100644 --- a/src/Aspire.Cli/Resources/NewCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/NewCommandStrings.Designer.cs @@ -138,5 +138,11 @@ public static string PromptToUsePrereleaseTemplates { return ResourceManager.GetString("PromptToUsePrereleaseTemplates", resourceCulture); } } + + public static string ResolvingTemplateVersion { + get { + return ResourceManager.GetString("ResolvingTemplateVersion", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/NewCommandStrings.resx b/src/Aspire.Cli/Resources/NewCommandStrings.resx index b42073476c3..7f35dde64ee 100644 --- a/src/Aspire.Cli/Resources/NewCommandStrings.resx +++ b/src/Aspire.Cli/Resources/NewCommandStrings.resx @@ -148,4 +148,7 @@ The programming language for the AppHost. + + Resolving template version... + diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs index 3ffb04ec849..2c608fb89cb 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs @@ -109,5 +109,11 @@ internal static string ProjectArgumentDescription { internal static string MigratedToNewSdkFormat => ResourceManager.GetString("MigratedToNewSdkFormat", resourceCulture); internal static string RemovedObsoleteAppHostPackage => ResourceManager.GetString("RemovedObsoleteAppHostPackage", resourceCulture); internal static string NoWritePermissionToInstallDirectory => ResourceManager.GetString("NoWritePermissionToInstallDirectory", resourceCulture); + internal static string CheckingForUpdates => ResourceManager.GetString("CheckingForUpdates", resourceCulture); + internal static string ApplyingUpdates => ResourceManager.GetString("ApplyingUpdates", resourceCulture); + internal static string ExtractingNewCli => ResourceManager.GetString("ExtractingNewCli", resourceCulture); + internal static string ExtractedNewCli => ResourceManager.GetString("ExtractedNewCli", resourceCulture); + internal static string RegeneratingSdkCode => ResourceManager.GetString("RegeneratingSdkCode", resourceCulture); + internal static string RegeneratedSdkCode => ResourceManager.GetString("RegeneratedSdkCode", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx index f79b9c98a49..458f75175c9 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx @@ -144,4 +144,22 @@ Cannot write to installation directory '{0}'. Please run the update with elevated permissions (e.g., using sudo on Linux/macOS). + + Checking for updates... + + + Applying updates... + + + Extracting new CLI... + + + Extracted new CLI + + + Regenerating SDK code with updated packages... + + + Regenerated SDK code with updated packages. + diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf index bb24461c342..0f8510ed398 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf @@ -7,6 +7,11 @@ Přidává se integrace hostování Aspire... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Přidejte do hostitele aplikací Aspire integraci hostování. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf index d78167fb247..14767e1b9d4 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf @@ -7,6 +7,11 @@ Aspire-Hosting-Integration wird hinzugefügt... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Fügen Sie dem Aspire AppHost eine Hosting-Integration hinzu. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf index e338b59f460..e4f29db42e6 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf @@ -7,6 +7,11 @@ Agregando integración de hospedaje de Aspire... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Agregue una integración de hospedaje a Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf index 5571a70a18b..85411608591 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf @@ -7,6 +7,11 @@ Ajout de l’intégration d’hébergement Aspire... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Ajoutez une intégration d’hébergement à l’Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf index c3247c18f84..4b58d0c925a 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf @@ -7,6 +7,11 @@ Aggiunta dell'integrazione di hosting Aspire in corso... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Aggiungere un'integrazione di hosting all'AppHost Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf index 90add5ffb24..4e1b1aa4004 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf @@ -7,6 +7,11 @@ Aspire ホスティング統合を追加しています... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Aspire AppHost にホスティング統合を追加します。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf index 1c03456afca..1fb392ff16e 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf @@ -7,6 +7,11 @@ Aspire 호스팅 통합을 추가하는 중... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Aspire AppHost에 호스팅 통합을 추가하세요. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf index 54c15d16df9..80e171960a2 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf @@ -7,6 +7,11 @@ Trwa dodawanie integracji hostingu platformy Aspire... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Dodaj integrację hostingu do hosta AppHost platformy Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf index 5ef5e738086..174232e941e 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf @@ -7,6 +7,11 @@ Adicionando integração de hosting do Aspire... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Adicione uma integração de hosting ao Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf index e245c37f520..97af4404f3a 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf @@ -7,6 +7,11 @@ Добавление интеграции размещения Aspire... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Добавьте интеграцию размещения в Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf index 3b2e6350c61..0dfe380f9b3 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf @@ -7,6 +7,11 @@ Aspire barındırma tümleştirmesi ekleniyor... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. Aspire AppHost'a bir barındırma tümleştirmesi ekleyin. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf index 563019e9b67..f55a676a7c6 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf @@ -7,6 +7,11 @@ 正在添加 Aspire 托管集成... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. 将托管集成添加到 Aspire AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf index 1a196dee737..e2c1b243074 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf @@ -7,6 +7,11 @@ 正在新增 Aspire 主機整合... + + Checking for running instances... + Checking for running instances... + + Add a hosting integration to the apphost. 將主機整合新增到 Aspire AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.cs.xlf index 04e77e8310d..77985587fa2 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.cs.xlf @@ -47,6 +47,11 @@ Nenašel se žádný soubor řešení. Vytváří se AppHost s jedním souborem... + + Resolving template version... + Resolving template version... + + Solution detected: {0} Zjištěné řešení: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.de.xlf index 0e9c4e74db6..fd2a2dcb6cc 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.de.xlf @@ -47,6 +47,11 @@ Keine Lösungsdatei gefunden. AppHost mit einzelner Datei wird erstellt … + + Resolving template version... + Resolving template version... + + Solution detected: {0} Lösung erkannt: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.es.xlf index c15d61fc51d..1903b29a032 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.es.xlf @@ -47,6 +47,11 @@ No se encontró ningún archivo de solución. Creando AppHost de un solo archivo... + + Resolving template version... + Resolving template version... + + Solution detected: {0} Solución detectada: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.fr.xlf index ac5b6eb918a..6c44497d7b6 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.fr.xlf @@ -47,6 +47,11 @@ Fichier de solution introuvable. Création d’un AppHost à fichier unique... + + Resolving template version... + Resolving template version... + + Solution detected: {0} Solution détectée : {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.it.xlf index 520e4717f2f..92ffe6e9570 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.it.xlf @@ -47,6 +47,11 @@ Nessun file di soluzione trovato. Creazione di AppHost a file singolo in corso... + + Resolving template version... + Resolving template version... + + Solution detected: {0} Soluzione rilevata: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ja.xlf index 416fc2ee7d8..3acbcc62965 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ja.xlf @@ -47,6 +47,11 @@ ソリューション ファイルが見つかりません。単一ファイルの AppHost を作成しています... + + Resolving template version... + Resolving template version... + + Solution detected: {0} ソリューションが検出されました: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ko.xlf index bc29fe515c2..0c729e510ec 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ko.xlf @@ -47,6 +47,11 @@ 솔루션 파일을 찾을 수 없습니다. 단일 파일 AppHost를 만드는 중... + + Resolving template version... + Resolving template version... + + Solution detected: {0} 솔루션 검색됨: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pl.xlf index dbf04baf696..bc20d2a4897 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pl.xlf @@ -47,6 +47,11 @@ Nie znaleziono pliku rozwiązania. Trwa tworzenie hosta aplikacji (AppHost) z jednym plikiem... + + Resolving template version... + Resolving template version... + + Solution detected: {0} Wykryto rozwiązanie: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pt-BR.xlf index d96c76b70b9..6a1855c9316 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pt-BR.xlf @@ -47,6 +47,11 @@ Não há arquivos de solução abertos. Criando o AppHost de arquivo único... + + Resolving template version... + Resolving template version... + + Solution detected: {0} Solução detectada: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ru.xlf index 1d518575e08..dc12fdf76cf 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ru.xlf @@ -47,6 +47,11 @@ Файл решения не найден. Создание однофайлового AppHost... + + Resolving template version... + Resolving template version... + + Solution detected: {0} Обнаружено решение: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.tr.xlf index 05571936352..97a32f836c1 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.tr.xlf @@ -47,6 +47,11 @@ Çözüm dosyası bulunamadı. Tek dosyalı AppHost oluşturuluyor... + + Resolving template version... + Resolving template version... + + Solution detected: {0} Çözüm algılandı: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hans.xlf index b3721872253..f3257b8e8b7 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hans.xlf @@ -47,6 +47,11 @@ 找不到解决方案文件。正在创建单文件 AppHost... + + Resolving template version... + Resolving template version... + + Solution detected: {0} 检测到解决方案: {0} diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hant.xlf index 2396e175973..ad62f82e797 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hant.xlf @@ -47,6 +47,11 @@ 找不到解決方案檔案。正在建立單一檔案 AppHost... + + Resolving template version... + Resolving template version... + + Solution detected: {0} 偵測到的解決方案: {0} diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf index c20b8aba626..c99ed7ed0ca 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf @@ -47,6 +47,11 @@ Výstupní cesta pro projekt + + Resolving template version... + Resolving template version... + + Select a template: Vyberte šablonu: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf index 13dc8ba2eb8..47db6e1d2ca 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf @@ -47,6 +47,11 @@ Der Ausgabepfad für das Projekt. + + Resolving template version... + Resolving template version... + + Select a template: Wählen Sie eine Vorlage aus: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf index 8b92d9151a4..b4183d6a32c 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf @@ -47,6 +47,11 @@ La ruta de salida del proyecto. + + Resolving template version... + Resolving template version... + + Select a template: Seleccione una plantilla: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf index 8615786e812..2b12a209263 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf @@ -47,6 +47,11 @@ Le chemin de sortie du projet. + + Resolving template version... + Resolving template version... + + Select a template: Sélectionnez un modèle : diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf index e055850c46f..184fa6fcc38 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf @@ -47,6 +47,11 @@ Percorso di output per il progetto. + + Resolving template version... + Resolving template version... + + Select a template: Selezionare un modello: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf index eece148caff..92891d5986a 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf @@ -47,6 +47,11 @@ プロジェクトの出力パス。 + + Resolving template version... + Resolving template version... + + Select a template: テンプレートの選択: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf index 025216abe12..e893cc3fde6 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf @@ -47,6 +47,11 @@ 프로젝트의 출력 경로입니다. + + Resolving template version... + Resolving template version... + + Select a template: 템플릿 선택: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf index 9d7882f27a4..ee3c6691908 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf @@ -47,6 +47,11 @@ Ścieżka wyjściowa projektu. + + Resolving template version... + Resolving template version... + + Select a template: Wybierz szablon: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf index 9b042608297..459623b6bdf 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf @@ -47,6 +47,11 @@ O caminho de saída para o projeto. + + Resolving template version... + Resolving template version... + + Select a template: Selecione um modelo: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf index f4aac64b11f..0853531ca96 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf @@ -47,6 +47,11 @@ Выходной путь для проекта. + + Resolving template version... + Resolving template version... + + Select a template: Выберите шаблон: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf index b2ab0aa19eb..33af320fecb 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf @@ -47,6 +47,11 @@ Projenin çıkış yolu. + + Resolving template version... + Resolving template version... + + Select a template: Bir şablon seçin: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf index 2b5b6567f1e..64b628a08f3 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf @@ -47,6 +47,11 @@ 项目的输出路径。 + + Resolving template version... + Resolving template version... + + Select a template: 选择模板: diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf index 18e9b06f0ba..12e0ce2dafe 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf @@ -47,6 +47,11 @@ 專案的輸出路徑。 + + Resolving template version... + Resolving template version... + + Select a template: 選取範本: diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf index b0011508192..9e918b2a747 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf @@ -32,6 +32,11 @@ Chcete tyto změny použít v souboru NuGet.config? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. Centrální správa balíčků není v současné době přes aspire update podporována. @@ -47,6 +52,11 @@ Kanál, na který se má aktualizovat (stabilní, příprava, denní) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} Nepovedlo se najít element PackageVersion pro: {0} v: {1} @@ -77,6 +87,16 @@ Spouští se: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. Nepodařilo se zjistit soubory NuGet.config. @@ -177,6 +197,16 @@ Úroveň kvality, na kterou se má aktualizovat (stabilní, příprava, denní) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Odebráno: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf index b02244f1c36..1565748d480 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf @@ -32,6 +32,11 @@ Diese Änderungen in NuGet.config übernehmen? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. Die zentrale Paketverwaltung wird von „aspire update“ zurzeit nicht unterstützt. @@ -47,6 +52,11 @@ Kanal, auf den aktualisiert werden soll (stable, staging, daily) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} Das PackageVersion-Element für „{0}“ wurde in {1} nicht gefunden. @@ -77,6 +87,16 @@ Wird ausgeführt: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. Beim Auffinden der NuGet.config-Dateien ist ein Fehler aufgetreten. @@ -177,6 +197,16 @@ Qualitätsstufe, auf die aktualisiert werden soll (stable, staging, daily) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Entfernt: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf index 0ef985b205e..c2a11711aa0 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf @@ -32,6 +32,11 @@ ¿Aplicar estos cambios en NuGet.config? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. La administración central de paquetes no es compatible actualmente con "aspire update". @@ -47,6 +52,11 @@ Canal al que se va a actualizar (estable, de ensayo, diario) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} No se pudo encontrar el elemento PackageVersion para '{0}' en {1} @@ -77,6 +87,16 @@ Ejecutando: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. Error al detectar los archivos NuGet.config. @@ -177,6 +197,16 @@ Nivel de calidad al que se va a actualizar (estable, de ensayo, diario) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Quitado: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf index 781c85f444e..6db234e838a 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf @@ -32,6 +32,11 @@ Voulez-vous appliquer ces modifications à NuGet.config ? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. Actuellement, la gestion centralisée des packages n’est pas prise en charge par « aspire update ». @@ -47,6 +52,11 @@ Canal vers lequel effectuer une mise à jour (stable, gestion intermédiaire, quotidien) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} Élément PackageVersion introuvable pour « {0} » dans {1} @@ -77,6 +87,16 @@ Exécution : {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. Échec de la découverte des fichiers NuGet.config. @@ -177,6 +197,16 @@ Niveau de qualité vers lequel effectuer une mise à jour (stable, gestion intermédiaire, quotidien) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Supprimé : {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf index 08b452195a6..1c8dc06b660 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf @@ -32,6 +32,11 @@ Applicare le modifiche a NuGet.config? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. La gestione centralizzata dei pacchetti non è attualmente supportata da "aspire update". @@ -47,6 +52,11 @@ Canale da aggiornare a (stabile, staging, giornaliero) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} Non è possibile trovare l'elemento PackageVersion per '{0}' in {1} @@ -77,6 +87,16 @@ Esecuzione in corso: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. Non è possibile individuare i file NuGet.config. @@ -177,6 +197,16 @@ Livello di qualità da aggiornare a (stabile, staging, giornaliero) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Rimosso: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf index 14600c37930..d3ce03430c7 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf @@ -32,6 +32,11 @@ これらの変更を NuGet.config に適用しますか? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. 現在、中央パッケージ管理では、'aspire update' によるサポートがありません。 @@ -47,6 +52,11 @@ 更新先のチャネル (安定、ステージング、毎日) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} {1} 内に '{0}' の PackageVersion 要素が見つかりませんでした @@ -77,6 +87,16 @@ 次を実行しています: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. NuGet.config ファイルを検出できませんでした。 @@ -177,6 +197,16 @@ 更新先の品質レベル (安定、ステージング、毎日) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} 削除済み: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf index 8c5a2327796..19eaa99a2d2 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf @@ -32,6 +32,11 @@ NuGet.config에 이러한 변경 내용을 적용하시겠습니까? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. 중앙 패키지 관리는 현재 'aspire update'에서 지원되지 않습니다. @@ -47,6 +52,11 @@ 업데이트할 채널(안정, 스테이징, 데일리) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} {1}에서 '{0}'에 대한 PackageVersion 요소를 찾을 수 없습니다. @@ -77,6 +87,16 @@ 실행 중: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. NuGet.config 파일을 검색하지 못했습니다. @@ -177,6 +197,16 @@ 업데이트할 품질 수준(안정, 스테이징, 데일리) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} 제거됨: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf index 03fa52b5606..c370eaf8f74 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf @@ -32,6 +32,11 @@ Czy zastosować te zmiany do pliku NuGet.config? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. Centralne zarządzanie pakietami nie jest obecnie obsługiwane przez „aktualizację Aspire”. @@ -47,6 +52,11 @@ Kanał, do którego należy zaktualizować (stabilny, przejściowy, dzienny) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} Nie można odnaleźć elementu PackageVersion dla „{0}” w {1} @@ -77,6 +87,16 @@ Wykonywanie: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. Nie można odnaleźć plików NuGet.config. @@ -177,6 +197,16 @@ Poziom jakości, do którego należy zaktualizować (stabilny, przejściowy, dzienny) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Usunięto: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf index f3c587be496..9a298067e2f 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf @@ -32,6 +32,11 @@ Aplicar essas alterações ao NuGet.config? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. No momento, o gerenciamento central de pacotes por “atualização de atualização” não ´possui suporte. @@ -47,6 +52,11 @@ Canal para o qual atualizar (estável, preparo, diariamente) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} Não foi possível localizar o elemento PackageVersion para "{0}" em {1} @@ -77,6 +87,16 @@ Executando: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. Falha ao descobrir os arquivos NuGet.config. @@ -177,6 +197,16 @@ Nível de qualidade para o qual atualizar (estável, preparo, diariamente) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Removido: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf index b81c4b90b1d..4c9b2bd5e48 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf @@ -32,6 +32,11 @@ Применить эти изменения к NuGet.config? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. Централизованное управление пакетами в настоящее время не поддерживается функцией "обновление Aspire". @@ -47,6 +52,11 @@ Канал для обновления (стабильный, промежуточный, ежедневный) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} Не удалось найти элемент PackageVersion для "{0}" в {1} @@ -77,6 +87,16 @@ Выполнение: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. Не удалось обнаружить файлы NuGet.config. @@ -177,6 +197,16 @@ Уровень качества для обновления (стабильный, промежуточный, ежедневный) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Удалено: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf index ee8f553e842..c05dc1cf87c 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf @@ -32,6 +32,11 @@ Bu değişiklikler NuGet.config dosyasına uygulansın mı? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. Merkezi paket yönetimi şu anda 'aspire update' tarafından desteklenmiyor. @@ -47,6 +52,11 @@ Güncelleştirme yapılacak kanal (kararlı, hazırlama, günlük) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} {1} içinde '{0}' için PackageVersion öğesi bulunamadı @@ -77,6 +87,16 @@ Yürütülüyor: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. NuGet.config dosyalarını bulunamadı. @@ -177,6 +197,16 @@ Güncelleştirilecek kalite seviyesi (kararlı, hazırlama, günlük) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} Kaldırıldı: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf index ae01d9f7b71..ca57f107045 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf @@ -32,6 +32,11 @@ 是否将这些更改应用到 NuGet.config? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. "aspire update" 当前不支持中央包管理。 @@ -47,6 +52,11 @@ 要更新到的频道(稳定、暂存、每日) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} 在 {1} 中找不到 ‘{0}’ 的 PackageVersion 元素 @@ -77,6 +87,16 @@ 正在执行: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. 未能发现 NuGet.config 文件。 @@ -177,6 +197,16 @@ 要更新到的质量级别(稳定、暂存、每日) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} 已移除: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf index 28782266096..47aedafc849 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf @@ -32,6 +32,11 @@ 是否將這些變更套用至 NuGet.config? + + Applying updates... + Applying updates... + + Central package management is currently not supported by 'aspire update'. 中央套件管理目前不受 'aspire update' 支援。 @@ -47,6 +52,11 @@ 要更新的通道 (穩定、暫存、每日) + + Checking for updates... + Checking for updates... + + Could not find PackageVersion element for '{0}' in {1} 在{1}中找不到 '{0}' 的 PackageVersion 元素 @@ -77,6 +87,16 @@ 正在執行: {0} + + Extracted new CLI + Extracted new CLI + + + + Extracting new CLI... + Extracting new CLI... + + Failed to discover NuGet.config files. 無法探索 NuGet.config 檔案。 @@ -177,6 +197,16 @@ 要更新的品質等級 (穩定、暫存、每日) + + Regenerated SDK code with updated packages. + Regenerated SDK code with updated packages. + + + + Regenerating SDK code with updated packages... + Regenerating SDK code with updated packages... + + Removed: {0} 已移除: {0} diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs index e711728f124..af4446f21ac 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs @@ -41,15 +41,13 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla var defaultOutputPath = $"./{projectName}"; outputPath = await _prompter.PromptForOutputPath(defaultOutputPath, cancellationToken); } - if (!Path.IsPathRooted(outputPath)) - { - outputPath = Path.Combine(_executionContext.WorkingDirectory.FullName, outputPath); - } + outputPath = Path.GetFullPath(outputPath, _executionContext.WorkingDirectory.FullName); _logger.LogDebug("Applying empty AppHost template. LanguageId: {LanguageId}, Language: {LanguageDisplayName}, ProjectName: {ProjectName}, OutputPath: {OutputPath}.", languageId, language.DisplayName, projectName, outputPath); var useLocalhostTld = await ResolveUseLocalhostTldAsync(parseResult, cancellationToken); + TemplateResult templateResult; try { if (!Directory.Exists(outputPath)) @@ -57,22 +55,40 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla Directory.CreateDirectory(outputPath); } - if (language.LanguageId.Value.Equals(Projects.KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase)) + var isCsharp = language.LanguageId.Value.Equals(Projects.KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase); + if (isCsharp) { - _logger.LogDebug("Using embedded C# empty AppHost template for '{OutputPath}'.", outputPath); - await WriteCSharpEmptyAppHostAsync(inputs.Version, outputPath, projectName, useLocalhostTld, cancellationToken); + // Do this first so there is no prompt while status is displayed for creating project. await _templateNuGetConfigService.PromptToCreateOrUpdateNuGetConfigAsync(inputs.Channel, outputPath, cancellationToken); } - else - { - _logger.LogDebug("Using scaffolding service for language '{LanguageDisplayName}' in '{OutputPath}'.", language.DisplayName, outputPath); - var context = new ScaffoldContext(language, new DirectoryInfo(outputPath), projectName); - await _scaffoldingService.ScaffoldAsync(context, cancellationToken); - if (useLocalhostTld) + templateResult = await _interactionService.ShowStatusAsync( + TemplatingStrings.CreatingNewProject, + async () => { - await ApplyLocalhostTldToScaffoldedRunProfileAsync(outputPath, projectName, cancellationToken); - } + if (isCsharp) + { + _logger.LogDebug("Using embedded C# empty AppHost template for '{OutputPath}'.", outputPath); + await WriteCSharpEmptyAppHostAsync(inputs.Version, outputPath, projectName, useLocalhostTld, cancellationToken); + } + else + { + _logger.LogDebug("Using scaffolding service for language '{LanguageDisplayName}' in '{OutputPath}'.", language.DisplayName, outputPath); + var context = new ScaffoldContext(language, new DirectoryInfo(outputPath), projectName); + await _scaffoldingService.ScaffoldAsync(context, cancellationToken); + + if (useLocalhostTld) + { + await ApplyLocalhostTldToScaffoldedRunProfileAsync(outputPath, projectName, cancellationToken); + } + } + + return new TemplateResult(ExitCodeConstants.Success, outputPath); + }, emoji: KnownEmojis.Rocket); + + if (templateResult.ExitCode != ExitCodeConstants.Success) + { + return templateResult; } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) @@ -84,7 +100,7 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla _interactionService.DisplaySuccess($"Created {language.DisplayName.EscapeMarkup()} project at {outputPath.EscapeMarkup()}"); _interactionService.DisplayMessage(KnownEmojis.Information, "Run 'aspire run' to start your AppHost."); - return new TemplateResult(ExitCodeConstants.Success, outputPath); + return templateResult; } private async Task ResolveUseLocalhostTldAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken) diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs index 2fcaa9b17e4..e62136447b2 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -31,15 +32,13 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT var defaultOutputPath = $"./{projectName}"; outputPath = await _prompter.PromptForOutputPath(defaultOutputPath, cancellationToken); } - if (!Path.IsPathRooted(outputPath)) - { - outputPath = Path.Combine(_executionContext.WorkingDirectory.FullName, outputPath); - } + outputPath = Path.GetFullPath(outputPath, _executionContext.WorkingDirectory.FullName); _logger.LogDebug("Applying TypeScript starter template. ProjectName: {ProjectName}, OutputPath: {OutputPath}, AspireVersion: {AspireVersion}.", projectName, outputPath, aspireVersion); var useLocalhostTld = await ResolveUseLocalhostTldAsync(parseResult, cancellationToken); + TemplateResult templateResult; try { if (!Directory.Exists(outputPath)) @@ -47,14 +46,42 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT Directory.CreateDirectory(outputPath); } - var projectNameLower = projectName.ToLowerInvariant(); + templateResult = await _interactionService.ShowStatusAsync( + TemplatingStrings.CreatingNewProject, + async () => + { + var projectNameLower = projectName.ToLowerInvariant(); + + // Generate random ports (matching .NET template port ranges) + var ports = GenerateRandomPorts(); + var hostName = useLocalhostTld ? $"{projectNameLower}.dev.localhost" : "localhost"; + string ApplyAllTokens(string content) => ApplyTokens(content, projectName, projectNameLower, aspireVersion, ports, hostName); + _logger.LogDebug("Copying embedded TypeScript starter template files to '{OutputPath}'.", outputPath); + await CopyTemplateTreeToDiskAsync("ts-starter", outputPath, ApplyAllTokens, cancellationToken); + + var npmPath = PathLookupHelper.FindFullPathFromPath("npm") ?? PathLookupHelper.FindFullPathFromPath("npm.cmd"); + if (npmPath is null) + { + _interactionService.DisplayError("npm is not installed or not found in PATH. Please install Node.js and try again."); + return new TemplateResult(ExitCodeConstants.InvalidCommand); + } - // Generate random ports (matching .NET template port ranges) - var ports = GenerateRandomPorts(); - var hostName = useLocalhostTld ? $"{projectNameLower}.dev.localhost" : "localhost"; - string ApplyAllTokens(string content) => ApplyTokens(content, projectName, projectNameLower, aspireVersion, ports, hostName); - _logger.LogDebug("Copying embedded TypeScript starter template files to '{OutputPath}'.", outputPath); - await CopyTemplateTreeToDiskAsync("ts-starter", outputPath, ApplyAllTokens, cancellationToken); + // Run npm install in the output directory (non-fatal — package may not be published yet) + _logger.LogDebug("Running npm install for TypeScript starter in '{OutputPath}'.", outputPath); + var npmInstallResult = await RunProcessAsync(npmPath, "install", outputPath, cancellationToken); + if (npmInstallResult.ExitCode != 0) + { + _interactionService.DisplaySubtleMessage("npm install had warnings or errors. You may need to run 'npm install' manually after dependencies are available."); + DisplayProcessOutput(npmInstallResult, treatStandardErrorAsError: false); + } + + return new TemplateResult(ExitCodeConstants.Success, outputPath); + }, emoji: KnownEmojis.Rocket); + + if (templateResult.ExitCode != ExitCodeConstants.Success) + { + return templateResult; + } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -62,25 +89,9 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT return new TemplateResult(ExitCodeConstants.FailedToCreateNewProject); } - var npmPath = PathLookupHelper.FindFullPathFromPath("npm") ?? PathLookupHelper.FindFullPathFromPath("npm.cmd"); - if (npmPath is null) - { - _interactionService.DisplayError("npm is not installed or not found in PATH. Please install Node.js and try again."); - return new TemplateResult(ExitCodeConstants.InvalidCommand); - } - - // Run npm install in the output directory (non-fatal — package may not be published yet) - _logger.LogDebug("Running npm install for TypeScript starter in '{OutputPath}'.", outputPath); - var npmInstallResult = await RunProcessAsync(npmPath, "install", outputPath, cancellationToken); - if (npmInstallResult.ExitCode != 0) - { - _interactionService.DisplaySubtleMessage("npm install had warnings or errors. You may need to run 'npm install' manually after dependencies are available."); - DisplayProcessOutput(npmInstallResult, treatStandardErrorAsError: false); - } - _interactionService.DisplaySuccess($"Created TypeScript starter project at {outputPath.EscapeMarkup()}"); _interactionService.DisplayMessage(KnownEmojis.Information, "Run 'aspire run' to start your AppHost."); - return new TemplateResult(ExitCodeConstants.Success, outputPath); + return templateResult; } } diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index c81def9d9d7..8f88caf1e52 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -456,7 +456,7 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, var templateInstallCollector = new OutputCollector(); var templateInstallResult = await interactionService.ShowStatusAsync<(int ExitCode, string? TemplateVersion)>( - $":ice: {TemplatingStrings.GettingTemplates}", + TemplatingStrings.GettingTemplates, async () => { var options = new DotNetCliRunnerInvocationOptions() @@ -480,7 +480,7 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, options: options, cancellationToken: cancellationToken); return result; - }); + }, emoji: KnownEmojis.Ice); if (templateInstallResult.ExitCode != 0) { diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 6bc8bece413..83c1e43acde 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1107,6 +1107,70 @@ public async Task NewCommandWithEmptyTemplateAndExplicitTypeScriptLanguageUsesSc Assert.True(scaffoldingInvoked); } + [Fact] + public async Task NewCommandWithEmptyTemplateNormalizesDefaultOutputPath() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + string? capturedTargetDirectory = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.NewCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestNewCommandPrompter(interactionService); + + // Accept the default "./TestApp" path from the prompt + prompter.PromptForOutputPathCallback = (path) => path; + + return prompter; + }; + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + { + var package = new NuGetPackage() + { + Id = "Aspire.ProjectTemplates", + Source = "nuget", + Version = "9.2.0" + }; + + return (0, new NuGetPackage[] { package }); + }; + + return runner; + }; + }); + + services.AddSingleton(new TestScaffoldingService + { + ScaffoldAsyncCallback = (context, cancellationToken) => + { + capturedTargetDirectory = context.TargetDirectory.FullName; + return Task.CompletedTask; + } + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + // Do not pass --output so the default "./TestApp" path is used via the prompter + var result = command.Parse("new --language typescript aspire-empty --name TestApp --localhost-tld false"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + Assert.NotNull(capturedTargetDirectory); + + // The output path should be properly normalized without "./" segments + Assert.DoesNotContain("./", capturedTargetDirectory); + Assert.DoesNotContain(".\\", capturedTargetDirectory); + + var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestApp"); + Assert.Equal(expectedPath, capturedTargetDirectory); + } + [Fact] public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTldAndUsesSelection() { From d0dd6052e667e2d7d0b7a313d363e4ddc1668377 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 2 Mar 2026 18:09:15 -0500 Subject: [PATCH 08/18] Bump VS Code extension version from 1.0.3 to 1.0.4 (#14860) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/package.json b/extension/package.json index 287da89acd2..3744fb344c8 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "Aspire", "description": "%extension.description%", "publisher": "microsoft-aspire", - "version": "1.0.3", + "version": "1.0.4", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "icon": "dotnet-aspire-logo-128.png", "license": "SEE LICENSE IN LICENSE.TXT", From 02a4294bd1bde872adb4130871557295b60d9940 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 2 Mar 2026 17:23:36 -0600 Subject: [PATCH 09/18] Add --disableProductStyleUrl to Azurite emulator args (#14855) Include --disableProductStyleUrl in Azurite command line when running Azure Storage as an emulator. This is necessary because when a Container resource references the Storage emulator it gets a URL like storage.dev.internal:10000. Using a URL like this conflicts between the Storage SDK and the emulator because the Storage SDK sees port 10000 and uses path-style URLs. But the emulator rejects them because it expects "product-style" URLs. Fix #14044 --- src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs | 3 ++- .../Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 9728286a584..d5903e7be76 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -251,11 +251,12 @@ public static IResourceBuilder RunAsEmulator(this IResourc // The default arguments list is coming from https://github.com/Azure/Azurite/blob/c3f93445fbd8fd54d380eb265a5665166c460d2b/Dockerfile#L47C6-L47C106 // They need to be repeated in order to be able to add --skipApiVersionCheck + // --disableProductStyleUrl is required to ensure the emulator uses path-style URLs, and not “product-style” URLs which have the account name in the host name of the URL. var surrogate = new AzureStorageEmulatorResource(builder.Resource); var surrogateBuilder = builder.ApplicationBuilder .CreateResourceBuilder(surrogate) - .WithArgs("azurite", "-l", "/data", "--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0", SkipApiVersionCheckArgument); + .WithArgs("azurite", "-l", "/data", "--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0", "--disableProductStyleUrl", SkipApiVersionCheckArgument); configureContainer?.Invoke(surrogateBuilder); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs index 7601cc7b49a..29e0b366b49 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs @@ -146,7 +146,7 @@ public async Task AddAzureStorage_WithApiVersionCheck_ShouldSetSkipApiVersionChe var args = await ArgumentEvaluator.GetArgumentListAsync(storage.Resource); - Assert.All(["azurite", "-l", "/data", "--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0"], x => args.Contains(x)); + Assert.All(["azurite", "-l", "/data", "--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0", "--disableProductStyleUrl"], x => args.Contains(x)); if (enableApiVersionCheck) { @@ -167,6 +167,7 @@ public async Task AddAzureStorage_RunAsEmulator_SetSkipApiVersionCheck() var args = await ArgumentEvaluator.GetArgumentListAsync(storage.Resource); Assert.Contains("--skipApiVersionCheck", args); + Assert.Contains("--disableProductStyleUrl", args); } [Fact] From 547b7e071c2dc958b7a5d583e667f2f12cc58c76 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:01:28 +0000 Subject: [PATCH 10/18] [release/13.2] Move CI trigger patterns out of ci.yml into a separate file (#14748) * feat: add CI trigger patterns file Extract the CI-skip glob patterns into eng/testing/github-ci-trigger-patterns.txt so that pattern maintenance is decoupled from the workflow definition. Changing this file alone will not trigger CI, avoiding the chicken-and-egg problem of the patterns being inlined in ci.yml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: read glob patterns from file in check-changed-files action Replace the inline regex patterns input with a patterns_file input that points to a file of glob patterns. Add a glob_to_regex() function that converts glob syntax (**, *, literal dot) to anchored ERE regexes. The action now: - reads patterns from a file, skipping comments and blank lines - converts each glob to an anchored regex before matching - escapes regex metacharacters (. + ? [ ] ( ) |) in glob literals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: use patterns_file in CI workflow Replace the inline regex patterns block in ci.yml with a single patterns_file reference to eng/testing/github-ci-trigger-patterns.txt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add CI trigger patterns documentation Explain the patterns file, its glob syntax, how to add new patterns, and how the check-changed-files action converts globs to ERE regexes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Ankit Jain Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actions/check-changed-files/action.yml | 92 +++++++++++++------ .github/workflows/ci.yml | 19 +--- docs/ci/ci-trigger-patterns.md | 71 ++++++++++++++ eng/testing/github-ci-trigger-patterns.txt | 49 ++++++++++ 4 files changed, 186 insertions(+), 45 deletions(-) create mode 100644 docs/ci/ci-trigger-patterns.md create mode 100644 eng/testing/github-ci-trigger-patterns.txt diff --git a/.github/actions/check-changed-files/action.yml b/.github/actions/check-changed-files/action.yml index 4e44385e177..f012557ea88 100644 --- a/.github/actions/check-changed-files/action.yml +++ b/.github/actions/check-changed-files/action.yml @@ -1,12 +1,19 @@ name: 'Check Changed Files' description: | - Check if all changed files in a PR match provided regex patterns. + Check if all changed files in a PR match provided glob patterns. - This action compares changed files in a pull request against one or more regex patterns + This action compares changed files in a pull request against one or more glob patterns and determines if all changed files match at least one of the provided patterns. + It only supports pull_request events. Inputs: - - patterns: List of regex patterns (multiline string) to match against changed file paths + - patterns_file: Path to a file containing glob patterns (relative to repository root). + Lines starting with '#' and blank lines are ignored. + + Pattern syntax: + - ** matches any path including directory separators (recursive) + - * matches any characters except a directory separator + - . is treated as a literal dot (no escaping needed) Outputs: - only_changed: Boolean indicating if all changed files matched the patterns @@ -15,8 +22,8 @@ description: | - matched_files: JSON array of files that matched at least one pattern - unmatched_files: JSON array of files that didn't match any pattern inputs: - patterns: - description: 'List of regex patterns to match against changed files' + patterns_file: + description: 'Path to a file containing glob patterns (relative to repository root)' required: true outputs: @@ -57,12 +64,60 @@ runs: exit 1 fi - # Read patterns from input (multiline string) - PATTERNS_INPUT="${{ inputs.patterns }}" + # Convert a glob pattern to an anchored ERE regex pattern. + # Glob syntax supported: + # ** matches any path including directory separators + # * matches any characters except a directory separator + # . is treated as a literal dot + # All other characters are treated as literals. + glob_to_regex() { + local glob="$1" + local result="$glob" + # Replace ** and * with placeholders before escaping + result="${result//\*\*/__DOUBLESTAR__}" + result="${result//\*/__STAR__}" + # Escape regex metacharacters that could appear in file paths. + # Note: { } ^ $ are not escaped because they are either not special + # in ERE mid-pattern or cannot appear in file paths. + result="${result//\\/\\\\}" + result="${result//./\\.}" + result="${result//+/\\+}" + result="${result//\?/\\?}" + result="${result//\[/\\[}" + result="${result//\]/\\]}" + result="${result//\(/\\(}" + result="${result//\)/\\)}" + result="${result//|/\\|}" + # Restore glob placeholders as regex + result="${result//__STAR__/[^/]*}" + result="${result//__DOUBLESTAR__/.*}" + # Anchor to full path + echo "^${result}$" + } + + PATTERNS=() + + PATTERNS_FILE="${{ inputs.patterns_file }}" - # Validate patterns input - if [ -z "$PATTERNS_INPUT" ]; then - echo "Error: patterns input is required" + # Read glob patterns from file, skip comments and blank lines + FULL_PATH="${GITHUB_WORKSPACE}/${PATTERNS_FILE}" + if [ ! -f "$FULL_PATH" ]; then + echo "Error: patterns_file '$FULL_PATH' not found" + exit 1 + fi + while IFS= read -r line; do + # Remove leading/trailing whitespace + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + # Skip blank lines and comments + if [ -z "$line" ] || [[ "$line" == \#* ]]; then + continue + fi + PATTERNS+=("$(glob_to_regex "$line")") + done < "$FULL_PATH" + + # Check if we have any valid patterns + if [ ${#PATTERNS[@]} -eq 0 ]; then + echo "Error: No valid patterns provided" exit 1 fi @@ -77,23 +132,6 @@ runs: echo "Changed files:" echo "$CHANGED_FILES" - # Convert patterns to array and filter out empty lines - PATTERNS=() - while IFS= read -r pattern; do - # Remove leading/trailing whitespace - pattern=$(echo "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - # Skip empty patterns - if [ -n "$pattern" ]; then - PATTERNS+=("$pattern") - fi - done <<< "$PATTERNS_INPUT" - - # Check if we have any valid patterns - if [ ${#PATTERNS[@]} -eq 0 ]; then - echo "Error: No valid patterns provided" - exit 1 - fi - # Initialize arrays MATCHED_FILES=() UNMATCHED_FILES=() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0007e8030f..bc39f93bf69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,24 +37,7 @@ jobs: if: ${{ github.event_name == 'pull_request' }} uses: ./.github/actions/check-changed-files with: - # Patterns that do NOT require CI to run - patterns: | - \.md$ - eng/pipelines/.* - eng/test-configuration.json - \.github/workflows/apply-test-attributes.yml - \.github/workflows/backport.yml - \.github/workflows/dogfood-comment.yml - \.github/workflows/generate-api-diffs.yml - \.github/workflows/generate-ats-diffs.yml - \.github/workflows/labeler-*.yml - \.github/workflows/markdownlint*.yml - \.github/workflows/refresh-manifests.yml - \.github/workflows/pr-review-needed.yml - \.github/workflows/specialized-test-runner.yml - \.github/workflows/tests-outerloop.yml - \.github/workflows/tests-quarantine.yml - \.github/workflows/update-*.yml + patterns_file: eng/testing/github-ci-trigger-patterns.txt - id: compute_version_suffix name: Compute version suffix for PRs diff --git a/docs/ci/ci-trigger-patterns.md b/docs/ci/ci-trigger-patterns.md new file mode 100644 index 00000000000..ab7f4cbc67d --- /dev/null +++ b/docs/ci/ci-trigger-patterns.md @@ -0,0 +1,71 @@ +# CI Trigger Patterns + +## Overview + +The file `eng/testing/github-ci-trigger-patterns.txt` lists glob patterns for files whose changes do **not** require the full CI to run. + +When a pull request is opened or updated, the CI workflow (`ci.yml`) checks whether **all** changed files match at least one pattern in the file. If they do, the workflow is skipped (no build or test jobs run). This keeps CI fast for changes that only affect documentation, pipeline configuration, or unrelated workflow files. + +> **Note:** This mechanism applies only to **pull requests**. Pushes to `main` or `release/*` branches always run the full CI pipeline. The `check-changed-files` action explicitly rejects non-`pull_request` events. + +## Why a Separate File? + +Previously the patterns were inlined in `.github/workflows/ci.yml`. Any change to that file (even just adding a new pattern to skip CI) would trigger CI on itself. Moving the patterns to `eng/testing/github-ci-trigger-patterns.txt` decouples pattern maintenance from the workflow definition. + +## Pattern Syntax + +Patterns use a simple **glob** style: + +| Syntax | Meaning | +|--------|---------| +| `**` | Matches any path including directory separators (recursive) | +| `*` | Matches any characters except a directory separator | +| `.` | Treated as a literal dot — no backslash escaping needed | + +All other characters (letters, digits, `-`, `_`, `/`, etc.) are treated as literals. + +Lines starting with `#` and blank lines are ignored. + +### Examples + +```text +# All Markdown files anywhere in the repo +**.md + +# All files under eng/pipelines/ recursively +eng/pipelines/** + +# A specific file +eng/test-configuration.json + +# Workflow files matching a glob (e.g. labeler-promote.yml, labeler-train.yml) +.github/workflows/labeler-*.yml +``` + +## How to Add a New Pattern + +To add files whose changes should not trigger CI: + +1. Open `eng/testing/github-ci-trigger-patterns.txt`. +2. Add one pattern per line, optionally preceded by a comment. +3. Submit a PR — CI will not run for that PR if all changed files match the patterns. + +> **Tip:** Changing the patterns file itself is listed as a skippable change (`eng/testing/github-ci-trigger-patterns.txt`), so a PR that only updates this file will not trigger CI. + +## How It Works + +The `.github/actions/check-changed-files` composite action: + +1. Reads `eng/testing/github-ci-trigger-patterns.txt` from the checked-out repository. +2. Converts each glob pattern to an anchored ERE (Extended Regular Expression) regex: + - `**` → `.*` + - `*` → `[^/]*` + - `.` and other regex metacharacters (`+`, `?`, `[`, `]`, `(`, `)`, `|`) → escaped with `\` +3. For every file changed in the PR, checks whether the file path matches at least one of the converted regexes. +4. Outputs `only_changed=true` when every changed file matched, allowing the calling workflow to skip further jobs. + +## Related Files + +- `eng/testing/github-ci-trigger-patterns.txt` — the patterns file described on this page +- `.github/actions/check-changed-files/action.yml` — the composite action that reads and evaluates the patterns +- `.github/workflows/ci.yml` — the CI workflow that calls the action diff --git a/eng/testing/github-ci-trigger-patterns.txt b/eng/testing/github-ci-trigger-patterns.txt new file mode 100644 index 00000000000..0d0ae248697 --- /dev/null +++ b/eng/testing/github-ci-trigger-patterns.txt @@ -0,0 +1,49 @@ +# CI trigger patterns +# +# This file lists glob patterns for files whose changes do NOT require the full CI +# to run (e.g. documentation, non-build pipeline scripts, or specific workflow files +# that are unrelated to the build and test process). +# +# When all files changed in a pull request match at least one pattern here, the CI +# workflow is skipped. +# +# Pattern syntax: +# ** matches any path including directory separators (recursive) +# * matches any characters except a directory separator +# . is treated as a literal dot (no escaping needed) +# All other characters are treated as literals. +# +# Lines starting with '#' and blank lines are ignored. + +# This file itself - changing CI-skip patterns doesn't require a CI run. +# Note: this also means a syntax error introduced here won't be caught by CI, +# so take care when editing. Pattern conversion is validated by the +# check-changed-files action at runtime. +eng/testing/github-ci-trigger-patterns.txt + +# Documentation +**.md + +# Engineering pipeline scripts (Azure DevOps, not used in the GitHub CI build) +eng/pipelines/** +eng/test-configuration.json + +.github/instructions/** +.github/skills/** + +# GitHub workflow files that do not affect the CI build or test process +.github/workflows/apply-test-attributes.yml +.github/workflows/backmerge-release.yml +.github/workflows/backport.yml +.github/workflows/dogfood-comment.yml +.github/workflows/generate-api-diffs.yml +.github/workflows/generate-ats-diffs.yml +.github/workflows/labeler-*.yml +.github/workflows/markdownlint*.yml +.github/workflows/pr-review-needed.yml +.github/workflows/refresh-manifests.yml +.github/workflows/reproduce-flaky-tests.yml +.github/workflows/specialized-test-runner.yml +.github/workflows/tests-outerloop.yml +.github/workflows/tests-quarantine.yml +.github/workflows/update-*.yml From 788b194450b407089d906456802138f402d7a224 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Mon, 2 Mar 2026 23:36:08 -0500 Subject: [PATCH 11/18] [release/13.2][CI] Unified test splitting infrastructure for CI parallelization (#14858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add ExtractTestPartitions tool and CI test matrix scripts Add tooling for automated CI test splitting and matrix generation: - ExtractTestPartitions: .NET tool that scans test assemblies for [Trait("Partition", "N")] attributes to discover test partitions - split-test-projects-for-ci.ps1: discovers partitions from assemblies and generates per-project partition JSON files - build-test-matrix.ps1: builds a canonical test matrix JSON from per-project metadata files - expand-test-matrix-github.ps1: expands the canonical matrix into GitHub Actions format with per-OS job entries - split-test-matrix-by-deps.ps1: splits the expanded matrix by dependency type (no-nugets, requires-nugets, requires-cli-archive) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Replace TestRunsheetBuilder with TestEnumerationRunsheetBuilder Replace the old per-OS runsheet generation with a new metadata-driven approach: - Add TestEnumerationRunsheetBuilder.targets: generates per-project .tests-metadata.json files during build, containing project metadata (supported OSes, timeouts, dependencies, split configuration) - Update AfterSolutionBuild.targets: add _GenerateCanonicalMatrixJson target that invokes build-test-matrix.ps1 to produce a canonical test matrix from the metadata files - Update Testing.props: add default timeout values and _ShortName computation for CI display names - Update tests/Directory.Build.targets: replace ExtractTestClassNames with GenerateTestPartitionsForCI target, add _ShouldArchiveTests logic after Testing.targets import - Remove TestRunsheetBuilder.targets and GetTestProjects.proj Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Configure test projects for unified CI splitting infrastructure Update test project files to use the new TestEnumerationRunsheetBuilder: - Aspire.Hosting.Tests: enable SplitTestsOnCI with partition-based splitting via TestClassNamePrefixForCI - Aspire.Cli.EndToEnd.Tests: migrate from ExtractTestClassNamesForHelix to SplitTestsOnCI, add RequiresCliArchive property - Aspire.Deployment.EndToEnd.Tests: migrate to SplitTestsOnCI, enable RunOnGithubActionsLinux (controlled by OnlyDeploymentTests filter) - Aspire.EndToEnd.Tests: add RequiresNugets property - Aspire.Templates.Tests: migrate to SplitTestsOnCI, add RequiresNugets, RequiresTestSdk, EnablePlaywrightInstall, and timeout properties - Aspire.Playground.Tests: remove obsolete TestRunnerPreCommand Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify CI workflows to use unified test matrix Rework the CI test orchestration to use a single, unified test matrix: - enumerate-tests action: replace per-category build steps with a single build using TestEnumerationRunsheetBuilder, then expand via expand-test-matrix-github.ps1. Output a single all_tests matrix. - tests.yml: collapse 3 per-OS setup jobs into 1, replace 9 category×OS job groups with 4 dependency-based groups (no-nugets, no-nugets-overflow, requires-nugets, requires-cli-archive) - deployment-tests.yml: use the unified enumerate-tests action with OnlyDeploymentTests=true, consume all_tests output - run-tests.yml: pass through testProjectPath, timeouts, and extraTestArgs from matrix entries - Remove tests-runner.yml (superseded by run-tests.yml) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Helix targets to consume new partitions JSON format Update send-to-helix-templatestests.targets to read test class names from the new .tests-partitions.json format (produced by GenerateTestPartitionsForCI) instead of the old .tests.list format. Strip the 'class:' prefix from partition entries for compatibility with the existing Helix work item generation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Infrastructure.Tests for CI tooling validation Add a new test project that validates the CI test splitting infrastructure: - ExtractTestPartitionsTests: tests the ExtractTestPartitions tool using dynamically-generated mock assemblies with various trait and attribute configurations - BuildTestMatrixTests: validates build-test-matrix.ps1 canonical matrix generation from metadata files - ExpandTestMatrixGitHubTests: validates expand-test-matrix-github.ps1 GitHub Actions matrix expansion - SplitTestMatrixByDepsTests: validates split-test-matrix-by-deps.ps1 dependency-based matrix splitting - SplitTestProjectsTests: validates split-test-projects-for-ci.ps1 partition discovery - Shared TestDataBuilder and PowerShellCommand helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add CI testing documentation and rebalance-tests skill - docs/ci/TestingOnCI.md: comprehensive documentation covering the test splitting architecture, MSBuild properties, matrix generation pipeline, and how to add/rebalance test partitions - .github/skills/rebalance-tests/SKILL.md: agent skill for analyzing CI build timings, downloading TRX files, computing optimal partition assignments via greedy bin-packing, and applying changes - Update CLI E2E testing SKILL.md and README.md to reference the unified TestEnumerationRunsheetBuilder infrastructure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add partition traits to Aspire.Hosting.Tests for CI parallelization Add [Trait("Partition", "N")] attributes to 118 test classes in Aspire.Hosting.Tests, distributing them across 6 partitions. This enables the CI infrastructure to split this large test project into parallel jobs, reducing wall-clock test time. Partition assignments are balanced by estimated test duration using greedy bin-packing across the partitions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove rebalance-tests skill Will be developed separately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix ExtractTestPartitions race condition and remove silent fallback - Make build/run failures of ExtractTestPartitions hard errors instead of silently falling back to class-based splitting - Pre-build the tool in enumerate-tests action before parallel test matrix generation to eliminate file locking race condition Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CLI E2E tests: add missing PR env vars to nuget-dependent test steps The GITHUB_PR_NUMBER, GITHUB_PR_HEAD_SHA, and GH_TOKEN environment variables were only set on the non-nuget test steps, but CLI E2E tests have requiresNugets=true and always run via the nuget-dependent path. This caused 'aspire: command not found' because the install script could not authenticate or identify the PR to download artifacts from. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix GenerateTestPartitionsForCI running during GitHub Actions test execution The GenerateTestPartitionsForCI target was firing during test execution jobs in run-tests.yml because PrepareForHelix=true (passed for archiving) combined with RunOnAzdoHelix=true (default) satisfied the condition. This caused split-test-projects-for-ci.ps1 to run and fail since ExtractTestPartitions wasn't built in that job. Add IsGitHubActionsRunner!=true to the PrepareForHelix branch so partition generation only runs via GenerateCIPartitions=true on GitHub Actions (set by the enumerate-tests action) and via PrepareForHelix on AzDo Helix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix RepoRoot path quoting for Windows in GenerateTestPartitionsForCI On Windows, RepoRoot ends with backslash (e.g. D:\a\_work\1\s\). When wrapped in quotes for the PowerShell command, the trailing backslash escapes the closing quote, embedding a literal quote character in the path. TrimEnd the backslash before quoting to fix this. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/actions/enumerate-tests/action.yml | 193 ++---- .github/skills/cli-e2e-testing/SKILL.md | 2 +- .github/workflows/deployment-tests.yml | 122 ++-- .github/workflows/run-tests.yml | 36 +- .github/workflows/specialized-test-runner.yml | 2 +- .github/workflows/tests-runner.yml | 206 ------ .github/workflows/tests.yml | 242 +++---- docs/ci/TestingOnCI.md | 300 +++++++++ eng/AfterSolutionBuild.targets | 24 +- .../TestEnumerationRunsheetBuilder.targets | 122 ++++ .../TestRunsheetBuilder.targets | 124 ---- eng/Testing.props | 9 + eng/Testing.targets | 3 +- eng/pipelines/templates/BuildAndTest.yml | 1 + eng/scripts/build-test-matrix.ps1 | 330 ++++++++++ eng/scripts/expand-test-matrix-github.ps1 | 140 ++++ eng/scripts/split-test-matrix-by-deps.ps1 | 142 +++++ eng/scripts/split-test-projects-for-ci.ps1 | 186 ++++++ .../Aspire.Cli.EndToEnd.Tests.csproj | 12 +- tests/Aspire.Cli.EndToEnd.Tests/README.md | 8 +- .../Aspire.Deployment.EndToEnd.Tests.csproj | 13 +- .../Aspire.EndToEnd.Tests.csproj | 2 + .../AddConnectionStringTests.cs | 1 + .../Aspire.Hosting.Tests/AddParameterTests.cs | 1 + .../Aspire.Hosting.Tests/AppHostSmokeTests.cs | 1 + .../Docker/DockerfileBuildAnnotationTests.cs | 1 + ...ockerfileBuilderCallbackAnnotationTests.cs | 1 + .../DockerfileBuilderCallbackContextTests.cs | 1 + .../Docker/DockerfileBuilderEdgeCasesTests.cs | 1 + .../DockerfileBuilderIntegrationTests.cs | 1 + .../Docker/DockerfileBuilderTests.cs | 1 + .../Docker/DockerfileStageTests.cs | 1 + .../Docker/DockerfileStatementsTests.cs | 1 + .../Docker/WithDockerfileBuilderTests.cs | 1 + .../EndpointHostHelpersTests.cs | 1 + .../GenerateParameterDefaultTests.cs | 1 + .../McpServerEndpointAnnotationTests.cs | 1 + .../AsHttp2ServiceTests.cs | 1 + .../Aspire.Hosting.Tests.csproj | 4 + .../Aspire.Hosting.Tests/AspireStoreTests.cs | 1 + .../Ats/AtsCapabilityScannerTests.cs | 1 + .../Backchannel/AppHostBackchannelTests.cs | 1 + .../AuxiliaryBackchannelRpcTargetTests.cs | 1 + .../Backchannel/AuxiliaryBackchannelTests.cs | 1 + .../Backchannel/BackchannelContractTests.cs | 1 + .../Exec/ContainerResourceExecTests.cs | 1 + .../Exec/ProjectResourceExecTests.cs | 1 + .../Backchannel/JsonElementConversionTests.cs | 1 + .../Cli/CliOrphanDetectorTests.cs | 1 + .../Codespaces/CodespacesUrlRewriterTests.cs | 1 + .../ConnectionPropertiesExtensionsTests.cs | 1 + .../ContainerImagePushOptionsTests.cs | 1 + .../ContainerRegistryResourceTests.cs | 1 + .../ContainerTunnelTests.cs | 1 + .../Dashboard/DashboardLifecycleHookTests.cs | 1 + .../Dashboard/DashboardOptionsTests.cs | 1 + .../Dashboard/DashboardResourceTests.cs | 1 + .../Dashboard/DashboardServiceTests.cs | 1 + .../Dashboard/ResourcePublisherTests.cs | 1 + .../TransportOptionsValidatorTests.cs | 1 + .../Dcp/DcpCliArgsTests.cs | 1 + .../Dcp/DcpExecutorTests.cs | 1 + .../Dcp/DcpHostNotificationTests.cs | 1 + .../Dcp/DcpLogParserTests.cs | 1 + .../DcpVisibilityTests.cs | 1 + .../Devcontainers/SshRemoteOptionsTests.cs | 1 + ...ibutedApplicationBuilderExtensionsTests.cs | 1 + .../DistributedApplicationBuilderTests.cs | 1 + ...tributedApplicationModelExtensionsTests.cs | 1 + .../DistributedApplicationOptionsTests.cs | 1 + .../DistributedApplicationTests.cs | 1 + .../EndpointReferenceTests.cs | 1 + ...tributedApplicationBuilderEventingTests.cs | 1 + ...ExecutableResourceBuilderExtensionTests.cs | 1 + .../ExecutableResourceTests.cs | 1 + .../ExecutionConfigurationGathererTests.cs | 1 + .../ExpressionResolverTests.cs | 1 + .../ExternalServiceTests.cs | 1 + .../FileSystemServiceTests.cs | 1 + .../Health/HealthStatusTests.cs | 1 + .../Health/ResourceHealthCheckServiceTests.cs | 1 + .../Aspire.Hosting.Tests/HealthCheckTests.cs | 1 + .../InteractionServiceTests.cs | 1 + .../JsonFlattenerTests.cs | 1 + .../KestrelConfigTests.cs | 1 + .../LaunchSettingsSerializerContextTests.cs | 1 + tests/Aspire.Hosting.Tests/MSBuildTests.cs | 1 + .../ManifestGenerationTests.cs | 1 + tests/Aspire.Hosting.Tests/ModelNameTests.cs | 1 + .../OperationModesTests.cs | 1 + .../ApplicationOrchestratorTests.cs | 1 + .../Orchestrator/ParameterProcessorTests.cs | 1 + .../RelationshipEvaluatorTests.cs | 1 + .../PathLookupHelperTests.cs | 1 + .../DistributedApplicationPipelineTests.cs | 1 + .../Pipelines/PipelineLoggerProviderTests.cs | 1 + .../Pipelines/PipelineSummaryTests.cs | 1 + .../Aspire.Hosting.Tests/PortAllocatorTest.cs | 1 + .../ProjectResourceTests.cs | 1 + .../PublishAsConnectionStringTests.cs | 1 + .../PublishAsDockerfileTests.cs | 1 + .../Publishing/DeploymentStateManagerTests.cs | 1 + .../PipelineActivityReporterTests.cs | 1 + .../Publishing/PublishingExtensionsTests.cs | 1 + .../ResourceContainerImageManagerTests.cs | 1 + .../ReferenceExpressionTests.cs | 1 + .../RequiredCommandAnnotationTests.cs | 1 + .../ResourceCommandAnnotationTests.cs | 1 + .../ResourceCommandServiceTests.cs | 1 + .../ResourceDependencyTests.cs | 1 + .../ResourceExtensionsTests.cs | 1 + .../ResourceLoggerServiceTests.cs | 1 + .../ResourceNotificationTests.cs | 1 + .../ResourceWithProbeTests.cs | 1 + .../Schema/SchemaTests.cs | 1 + .../Aspire.Hosting.Tests/SecretsStoreTests.cs | 1 + .../SlimTestProgramTests.cs | 1 + .../StableConnectionStringBuilderTests.cs | 1 + .../UserSecretsParameterDefaultTests.cs | 1 + .../Utils/CommandLineArgsParserTests.cs | 1 + .../Utils/ContainerReferenceParserTests.cs | 1 + .../EnvironmentVariableNameEncoderTests.cs | 1 + .../Utils/PasswordGeneratorTests.cs | 1 + .../PeriodicRestartAsyncEnumerableTests.cs | 1 + .../Utils/StringComparersTests.cs | 1 + .../Utils/UseCultureAttributeTests.cs | 1 + .../Utils/VolumeNameGeneratorTests.cs | 1 + .../Utils/WithAnnotationTests.cs | 1 + .../ValueSnapshotTests.cs | 1 + .../PackageUpdateHelpersTests.cs | 1 + .../VersionCheckServiceTests.cs | 1 + tests/Aspire.Hosting.Tests/WaitForTests.cs | 1 + .../WithCertificateAuthorityCollection.cs | 1 + .../Aspire.Hosting.Tests/WithEndpointTests.cs | 1 + .../WithEnvironmentTests.cs | 1 + .../WithHttpCommandTests.cs | 1 + .../Aspire.Hosting.Tests/WithIconNameTests.cs | 1 + .../WithMcpServerTests.cs | 1 + .../WithOtlpExporterTests.cs | 1 + .../WithReferenceTests.cs | 1 + tests/Aspire.Hosting.Tests/WithUrlsTests.cs | 1 + .../Aspire.Playground.Tests.csproj | 8 - .../Aspire.Templates.Tests.csproj | 17 +- tests/Directory.Build.targets | 87 ++- .../ExtractTestPartitionsFixture.cs | 79 +++ .../ExtractTestPartitionsTests.cs | 345 ++++++++++ .../MockAssemblyBuilder.cs | 283 +++++++++ .../Infrastructure.Tests.csproj | 33 + .../PowerShellScripts/BuildTestMatrixTests.cs | 474 ++++++++++++++ .../ExpandTestMatrixGitHubTests.cs | 600 ++++++++++++++++++ .../PowerShellScripts/PowerShellCommand.cs | 330 ++++++++++ .../SplitTestMatrixByDepsTests.cs | 324 ++++++++++ .../SplitTestProjectsTests.cs | 228 +++++++ .../Shared/TestDataBuilder.cs | 336 ++++++++++ tests/Shared/GetTestProjects.proj | 50 -- .../send-to-helix-templatestests.targets | 12 +- .../ExtractTestPartitions.csproj | 10 + tools/ExtractTestPartitions/Program.cs | 125 ++++ 158 files changed, 4864 insertions(+), 818 deletions(-) delete mode 100644 .github/workflows/tests-runner.yml create mode 100644 docs/ci/TestingOnCI.md create mode 100644 eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets delete mode 100644 eng/TestRunsheetBuilder/TestRunsheetBuilder.targets create mode 100644 eng/scripts/build-test-matrix.ps1 create mode 100644 eng/scripts/expand-test-matrix-github.ps1 create mode 100644 eng/scripts/split-test-matrix-by-deps.ps1 create mode 100644 eng/scripts/split-test-projects-for-ci.ps1 create mode 100644 tests/Infrastructure.Tests/ExtractTestPartitions/ExtractTestPartitionsFixture.cs create mode 100644 tests/Infrastructure.Tests/ExtractTestPartitions/ExtractTestPartitionsTests.cs create mode 100644 tests/Infrastructure.Tests/ExtractTestPartitions/MockAssemblyBuilder.cs create mode 100644 tests/Infrastructure.Tests/Infrastructure.Tests.csproj create mode 100644 tests/Infrastructure.Tests/PowerShellScripts/BuildTestMatrixTests.cs create mode 100644 tests/Infrastructure.Tests/PowerShellScripts/ExpandTestMatrixGitHubTests.cs create mode 100644 tests/Infrastructure.Tests/PowerShellScripts/PowerShellCommand.cs create mode 100644 tests/Infrastructure.Tests/PowerShellScripts/SplitTestMatrixByDepsTests.cs create mode 100644 tests/Infrastructure.Tests/PowerShellScripts/SplitTestProjectsTests.cs create mode 100644 tests/Infrastructure.Tests/Shared/TestDataBuilder.cs delete mode 100644 tests/Shared/GetTestProjects.proj create mode 100644 tools/ExtractTestPartitions/ExtractTestPartitions.csproj create mode 100644 tools/ExtractTestPartitions/Program.cs diff --git a/.github/actions/enumerate-tests/action.yml b/.github/actions/enumerate-tests/action.yml index 45093b71df7..f13fb81b093 100644 --- a/.github/actions/enumerate-tests/action.yml +++ b/.github/actions/enumerate-tests/action.yml @@ -1,36 +1,32 @@ name: 'Enumerate test projects' description: 'Enumerate list of test projects' inputs: - includeIntegrations: + buildArgs: required: false - type: boolean - default: false - includeTemplates: - required: false - type: boolean - default: false - includeCliE2E: - required: false - type: boolean - default: false - includeDeployment: - required: false - type: boolean - default: false - + type: string + default: '' + description: 'Additional MSBuild arguments passed to the test matrix generation step (e.g., /p:IncludeTemplateTests=true /p:OnlyDeploymentTests=true)' + +# Output format: JSON with structure {"include": [{...}, ...]} +# Each entry contains: +# - type: 'regular' | 'collection' | 'class' +# - projectName: Full project name (e.g., 'Aspire.Hosting.Tests') +# - name: Display name for the test run +# - shortname: Short identifier +# - testProjectPath: Relative path to the test project +# - workitemprefix: Prefix for work item naming +# - runs-on: GitHub Actions runner (e.g., 'ubuntu-latest', 'windows-latest') +# - testSessionTimeout: Timeout for the test session (e.g., '20m') +# - testHangTimeout: Timeout for hung tests (e.g., '10m') +# - requiresNugets: Boolean indicating if NuGet packages are needed +# - requiresTestSdk: Boolean indicating if test SDK is needed +# - extraTestArgs: Additional test arguments (e.g., '--filter-trait "Partition=P1"') +# - collection: (collection type only) Collection/partition name +# - classname: (class type only) Fully qualified test class name outputs: - integrations_tests_matrix: - description: Integration tests matrix - value: ${{ steps.generate_integrations_matrix.outputs.integrations_tests_matrix }} - templates_tests_matrix: - description: Templates tests matrix - value: ${{ steps.generate_templates_matrix.outputs.templates_tests_matrix }} - cli_e2e_tests_matrix: - description: Cli E2E tests matrix - value: ${{ steps.generate_cli_e2e_matrix.outputs.cli_e2e_tests_matrix }} - deployment_tests_matrix: - description: Deployment E2E tests matrix - value: ${{ steps.generate_deployment_matrix.outputs.deployment_tests_matrix }} + all_tests: + description: Combined matrix of all test entries + value: ${{ steps.expand_matrix.outputs.all_tests }} runs: using: "composite" steps: @@ -42,123 +38,38 @@ runs: with: global-json-file: ${{ github.workspace }}/global.json - - name: Get list of integration tests - if: ${{ inputs.includeIntegrations }} - shell: pwsh - run: > - dotnet build ${{ github.workspace }}/tests/Shared/GetTestProjects.proj - /bl:${{ github.workspace }}/artifacts/log/Debug/GetTestProjects.binlog - /p:TestsListOutputPath=${{ github.workspace }}/artifacts/TestsForGithubActions.list - /p:ContinuousIntegrationBuild=true - - - name: Generate list of template tests - if: ${{ inputs.includeTemplates }} - shell: pwsh - run: > - dotnet build ${{ github.workspace }}/tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj - "/t:Build;ExtractTestClassNames" - /bl:${{ github.workspace }}/artifacts/log/Debug/BuildTemplatesTests.binlog - -p:ExtractTestClassNamesForHelix=true - -p:PrepareForHelix=true - -p:ExtractTestClassNamesPrefix=Aspire.Templates.Tests - -p:InstallBrowsersForPlaywright=false + - name: Restore + shell: bash + run: ./restore.sh - - name: Generate list of CLI E2E tests - if: ${{ inputs.includeCliE2E }} - shell: pwsh - run: > - dotnet build ${{ github.workspace }}/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj - "/t:Build;ExtractTestClassNames" - /bl:${{ github.workspace }}/artifacts/log/Debug/BuildCliEndToEndTests.binlog - -p:ExtractTestClassNamesForHelix=true - -p:PrepareForHelix=true - -p:ExtractTestClassNamesPrefix=Aspire.Cli.EndToEnd.Tests - -p:InstallBrowsersForPlaywright=false + - name: Build ExtractTestPartitions tool + shell: bash + run: dotnet build tools/ExtractTestPartitions/ExtractTestPartitions.csproj -c Release --nologo -v quiet - - name: Generate list of Deployment E2E tests - if: ${{ inputs.includeDeployment }} - shell: pwsh + - name: Generate canonical test matrix + shell: bash run: > - dotnet build ${{ github.workspace }}/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj - "/t:Build;ExtractTestClassNames" - /bl:${{ github.workspace }}/artifacts/log/Debug/BuildDeploymentEndToEndTests.binlog - -p:ExtractTestClassNamesForHelix=true - -p:PrepareForHelix=true - -p:ExtractTestClassNamesPrefix=Aspire.Deployment.EndToEnd.Tests - - - name: Generate tests matrix - id: generate_integrations_matrix - if: ${{ inputs.includeIntegrations }} - shell: pwsh - run: | - $filePath = "${{ github.workspace }}/artifacts/TestsForGithubActions.list" - $lines = Get-Content $filePath - $jsonObject = @{ - "shortname" = $lines | Sort-Object - } - $jsonString = ConvertTo-Json $jsonObject -Compress - "integrations_tests_matrix=$jsonString" - "integrations_tests_matrix=$jsonString" | Out-File -FilePath $env:GITHUB_OUTPUT - - - name: Generate templates matrix - id: generate_templates_matrix - if: ${{ inputs.includeTemplates }} - shell: pwsh - run: | - $inputFilePath = "${{ github.workspace }}/artifacts/helix/templates-tests/Aspire.Templates.Tests.tests.list" - $lines = Get-Content $inputFilePath - - $prefix = "Aspire.Templates.Tests." - $lines = Get-Content $inputFilePath | ForEach-Object { - $_ -replace "^$prefix", "" - } - - $jsonObject = @{ - "shortname" = $lines | Sort-Object - } - $jsonString = ConvertTo-Json $jsonObject -Compress - "templates_tests_matrix=$jsonString" - "templates_tests_matrix=$jsonString" | Out-File -FilePath $env:GITHUB_OUTPUT - - - name: Generate cli e2e matrix - id: generate_cli_e2e_matrix - if: ${{ inputs.includeCliE2E }} + ./build.sh -test + /p:TestRunnerName=TestEnumerationRunsheetBuilder + /p:TestMatrixOutputPath=artifacts/canonical-test-matrix.json + /p:GenerateCIPartitions=true + /bl + ${{ inputs.buildArgs }} + + - name: Expand matrix for GitHub Actions + id: expand_matrix shell: pwsh run: | - $inputFilePath = "${{ github.workspace }}/artifacts/helix/cli-e2e-tests/Aspire.Cli.EndToEnd.Tests.tests.list" - $lines = Get-Content $inputFilePath - - $prefix = "Aspire.Cli.EndToEnd.Tests." - $lines = @(Get-Content $inputFilePath | ForEach-Object { - $_ -replace "^$prefix", "" - }) - - $jsonObject = @{ - "shortname" = @($lines | Sort-Object) - } - $jsonString = ConvertTo-Json $jsonObject -Compress - "cli_e2e_tests_matrix=$jsonString" - "cli_e2e_tests_matrix=$jsonString" | Out-File -FilePath $env:GITHUB_OUTPUT - - - name: Generate deployment matrix - id: generate_deployment_matrix - if: ${{ inputs.includeDeployment }} - shell: pwsh - run: | - $inputFilePath = "${{ github.workspace }}/artifacts/helix/deployment-e2e-tests/Aspire.Deployment.EndToEnd.Tests.tests.list" - $lines = Get-Content $inputFilePath - - $prefix = "Aspire.Deployment.EndToEnd.Tests." - $lines = @(Get-Content $inputFilePath | ForEach-Object { - $_ -replace "^$prefix", "" - }) - - $jsonObject = @{ - "shortname" = @($lines | Sort-Object) + $canonicalMatrixPath = "${{ github.workspace }}/artifacts/canonical-test-matrix.json" + $expandScript = "${{ github.workspace }}/eng/scripts/expand-test-matrix-github.ps1" + + if (Test-Path $canonicalMatrixPath) { + & $expandScript -CanonicalMatrixFile $canonicalMatrixPath -OutputToGitHubEnv + } else { + $emptyMatrix = '{"include":[]}' + "all_tests=$emptyMatrix" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "No canonical test matrix found, using empty matrix" } - $jsonString = ConvertTo-Json $jsonObject -Compress - "deployment_tests_matrix=$jsonString" - "deployment_tests_matrix=$jsonString" | Out-File -FilePath $env:GITHUB_OUTPUT - name: Upload logs if: always() @@ -167,4 +78,6 @@ runs: name: logs-enumerate-tests-${{ runner.os }} path: | artifacts/log/**/*.binlog - artifacts/**/*.list + artifacts/**/*tests-partitions.json + artifacts/**/*tests-metadata.json + artifacts/canonical-test-matrix.json diff --git a/.github/skills/cli-e2e-testing/SKILL.md b/.github/skills/cli-e2e-testing/SKILL.md index 8234bee8e3f..6217aa7bf28 100644 --- a/.github/skills/cli-e2e-testing/SKILL.md +++ b/.github/skills/cli-e2e-testing/SKILL.md @@ -375,7 +375,7 @@ Environment variables set in CI: - `GH_TOKEN`: GitHub token for API access - `GITHUB_WORKSPACE`: Workspace root for artifact paths -Each test class runs as a separate CI job via `CliEndToEndTestRunsheetBuilder` for parallel execution. +Each test class runs as a separate CI job via the unified `TestEnumerationRunsheetBuilder` infrastructure (using `SplitTestsOnCI=true`) for parallel execution. ## CI Troubleshooting diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index 5643a0d8a05..41128c915e9 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -45,7 +45,7 @@ jobs: run: | PR_NUMBER="${{ inputs.pr_number }}" RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - + gh pr comment "${PR_NUMBER}" --repo "${{ github.repository }}" --body \ "🚀 **Deployment tests starting** on PR #${PR_NUMBER}... @@ -61,19 +61,19 @@ jobs: permissions: contents: read outputs: - matrix: ${{ steps.enumerate.outputs.deployment_tests_matrix }} + matrix: ${{ steps.enumerate.outputs.all_tests }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/enumerate-tests id: enumerate with: - includeDeployment: true + buildArgs: '/p:OnlyDeploymentTests=true' - name: Display test matrix run: | echo "Deployment test matrix:" - echo '${{ steps.enumerate.outputs.deployment_tests_matrix }}' | jq . + echo '${{ steps.enumerate.outputs.all_tests }}' | jq . # Build solution and CLI once, share via artifacts build: @@ -107,16 +107,16 @@ jobs: ARTIFACT_DIR="${{ github.workspace }}/cli-artifacts" mkdir -p "$ARTIFACT_DIR/bin" mkdir -p "$ARTIFACT_DIR/packages" - + # Copy CLI binary and dependencies cp -r "${{ github.workspace }}/artifacts/bin/Aspire.Cli/Release/net10.0/"* "$ARTIFACT_DIR/bin/" - + # Copy NuGet packages PACKAGES_DIR="${{ github.workspace }}/artifacts/packages/Release/Shipping" if [ -d "$PACKAGES_DIR" ]; then find "$PACKAGES_DIR" -name "*.nupkg" -exec cp {} "$ARTIFACT_DIR/packages/" \; fi - + echo "CLI artifacts prepared:" ls -la "$ARTIFACT_DIR/bin/" echo "Package count: $(find "$ARTIFACT_DIR/packages" -name "*.nupkg" | wc -l)" @@ -132,7 +132,7 @@ jobs: deploy-test: name: Deploy (${{ matrix.shortname }}) needs: [enumerate, build] - if: ${{ needs.enumerate.outputs.matrix != '{"shortname":[]}' && needs.enumerate.outputs.matrix != '' }} + if: ${{ needs.enumerate.outputs.matrix != '{"include":[]}' && needs.enumerate.outputs.matrix != '' }} runs-on: 8-core-ubuntu-latest environment: deployment-testing permissions: @@ -170,22 +170,22 @@ jobs: run: | ASPIRE_HOME="$HOME/.aspire" mkdir -p "$ASPIRE_HOME/bin" - + # Copy CLI binary and dependencies cp -r "${{ github.workspace }}/cli-artifacts/bin/"* "$ASPIRE_HOME/bin/" chmod +x "$ASPIRE_HOME/bin/aspire" - + # Add to PATH for this job echo "$ASPIRE_HOME/bin" >> $GITHUB_PATH - + # Set up NuGet hive for local packages HIVE_DIR="$ASPIRE_HOME/hives/local/packages" mkdir -p "$HIVE_DIR" cp "${{ github.workspace }}/cli-artifacts/packages/"*.nupkg "$HIVE_DIR/" 2>/dev/null || true - + # Configure CLI to use local channel "$ASPIRE_HOME/bin/aspire" config set channel local --global || true - + echo "✅ Aspire CLI installed:" "$ASPIRE_HOME/bin/aspire" --version @@ -199,7 +199,7 @@ jobs: script: | const token = await core.getIDToken('api://AzureADTokenExchange'); core.setSecret(token); - + // Login directly - token never leaves this step await exec.exec('az', [ 'login', '--service-principal', @@ -208,7 +208,7 @@ jobs: '--federated-token', token, '--allow-no-subscriptions' ]); - + await exec.exec('az', [ 'account', 'set', '--subscription', process.env.AZURE_SUBSCRIPTION_ID @@ -245,7 +245,7 @@ jobs: --results-directory ${{ github.workspace }}/testresults \ -- \ --filter-not-trait "quarantined=true" \ - --filter-class "Aspire.Deployment.EndToEnd.Tests.${{ matrix.shortname }}" \ + ${{ matrix.extraTestArgs }} \ || echo "test_failed=true" >> $GITHUB_OUTPUT - name: Upload test results @@ -361,7 +361,7 @@ jobs: script: | const fs = require('fs'); const path = require('path'); - + // Get all jobs for this workflow run to determine per-test results const jobs = await github.paginate( github.rest.actions.listJobsForWorkflowRun, @@ -372,23 +372,23 @@ jobs: per_page: 100 } ); - + console.log(`Total jobs found: ${jobs.length}`); - + // Filter for deploy-test matrix jobs (format: "Deploy (TestClassName)") const deployJobs = jobs.filter(job => job.name.startsWith('Deploy (')); - + const passedTests = []; const failedTests = []; const cancelledTests = []; - + for (const job of deployJobs) { // Extract test name from job name "Deploy (TestClassName)" const match = job.name.match(/^Deploy \((.+)\)$/); const testName = match ? match[1] : job.name; - + console.log(`Job "${job.name}" - conclusion: ${job.conclusion}, status: ${job.status}`); - + if (job.conclusion === 'success') { passedTests.push(testName); } else if (job.conclusion === 'failure') { @@ -397,15 +397,15 @@ jobs: cancelledTests.push(testName); } } - + console.log(`Passed: ${passedTests.length}, Failed: ${failedTests.length}, Cancelled: ${cancelledTests.length}`); - + // Output results for later steps core.setOutput('passed_tests', JSON.stringify(passedTests)); core.setOutput('failed_tests', JSON.stringify(failedTests)); core.setOutput('cancelled_tests', JSON.stringify(cancelledTests)); core.setOutput('total_tests', passedTests.length + failedTests.length + cancelledTests.length); - + // List all artifacts for the current workflow run const allArtifacts = await github.paginate( github.rest.actions.listWorkflowRunArtifacts, @@ -416,53 +416,53 @@ jobs: per_page: 100 } ); - + console.log(`Total artifacts found: ${allArtifacts.length}`); - + // Filter for deployment test recording artifacts - const recordingArtifacts = allArtifacts.filter(a => + const recordingArtifacts = allArtifacts.filter(a => a.name.startsWith('deployment-test-recordings-') ); - + console.log(`Found ${recordingArtifacts.length} recording artifacts`); - + // Create recordings directory const recordingsDir = 'recordings'; fs.mkdirSync(recordingsDir, { recursive: true }); - + // Download each artifact for (const artifact of recordingArtifacts) { console.log(`Downloading ${artifact.name}...`); - + const download = await github.rest.actions.downloadArtifact({ owner: context.repo.owner, repo: context.repo.repo, artifact_id: artifact.id, archive_format: 'zip' }); - + const artifactPath = path.join(recordingsDir, `${artifact.name}.zip`); fs.writeFileSync(artifactPath, Buffer.from(download.data)); console.log(`Saved to ${artifactPath}`); } - + core.setOutput('artifact_count', recordingArtifacts.length); - name: Extract recordings from artifacts shell: bash run: | mkdir -p cast_files - + for zipfile in recordings/*.zip; do if [ -f "$zipfile" ]; then echo "Extracting $zipfile..." unzip -o "$zipfile" -d "recordings/extracted_$(basename "$zipfile" .zip)" || true fi done - + # Find and copy all .cast files find recordings -name "*.cast" -exec cp {} cast_files/ \; 2>/dev/null || true - + echo "Found recordings:" ls -la cast_files/ || echo "No .cast files found" @@ -479,12 +479,12 @@ jobs: RUN_ID="${{ github.run_id }}" RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${RUN_ID}" TEST_RESULT="${{ needs.deploy-test.result }}" - + # Parse the test results from JSON PASSED_COUNT=$(echo "$PASSED_TESTS" | jq 'length') FAILED_COUNT=$(echo "$FAILED_TESTS" | jq 'length') CANCELLED_COUNT=$(echo "$CANCELLED_TESTS" | jq 'length') - + # Determine overall status if [ "$FAILED_COUNT" -gt 0 ]; then EMOJI="❌" @@ -499,66 +499,66 @@ jobs: EMOJI="❓" STATUS="unknown" fi - + # Build the comment header COMMENT_BODY="${EMOJI} **Deployment E2E Tests ${STATUS}** - + **Summary:** ${PASSED_COUNT} passed, ${FAILED_COUNT} failed, ${CANCELLED_COUNT} cancelled - + [View workflow run](${RUN_URL})" - + # Add passed tests section if any if [ "$PASSED_COUNT" -gt 0 ]; then PASSED_LIST=$(echo "$PASSED_TESTS" | jq -r '.[]' | while read test; do echo "- ✅ ${test}"; done) COMMENT_BODY="${COMMENT_BODY} - + ### Passed Tests ${PASSED_LIST}" fi - + # Add failed tests section if any if [ "$FAILED_COUNT" -gt 0 ]; then FAILED_LIST=$(echo "$FAILED_TESTS" | jq -r '.[]' | while read test; do echo "- ❌ ${test}"; done) COMMENT_BODY="${COMMENT_BODY} - + ### Failed Tests ${FAILED_LIST}" fi - + # Add cancelled tests section if any if [ "$CANCELLED_COUNT" -gt 0 ]; then CANCELLED_LIST=$(echo "$CANCELLED_TESTS" | jq -r '.[]' | while read test; do echo "- ⚠️ ${test}"; done) COMMENT_BODY="${COMMENT_BODY} - + ### Cancelled Tests ${CANCELLED_LIST}" fi - + # Check for recordings and upload them RECORDINGS_DIR="cast_files" - + if [ -d "$RECORDINGS_DIR" ] && compgen -G "$RECORDINGS_DIR"/*.cast > /dev/null; then # Install asciinema pip install --quiet asciinema - + RECORDING_TABLE=" - + ### 🎬 Terminal Recordings - + | Test | Recording | |------|-----------|" - + UPLOAD_COUNT=0 - + for castfile in "$RECORDINGS_DIR"/*.cast; do if [ -f "$castfile" ]; then filename=$(basename "$castfile" .cast) echo "Uploading $castfile..." - + # Upload to asciinema and capture URL UPLOAD_OUTPUT=$(asciinema upload "$castfile" 2>&1) || true ASCIINEMA_URL=$(echo "$UPLOAD_OUTPUT" | grep -oP 'https://asciinema\.org/a/[a-zA-Z0-9_-]+' | head -1) || true - + if [ -n "$ASCIINEMA_URL" ]; then RECORDING_TABLE="${RECORDING_TABLE} | ${filename} | [▶️ View Recording](${ASCIINEMA_URL}) |" @@ -571,16 +571,16 @@ jobs: fi fi done - + if [ $UPLOAD_COUNT -gt 0 ]; then COMMENT_BODY="${COMMENT_BODY}${RECORDING_TABLE}" fi - + echo "Uploaded $UPLOAD_COUNT recordings" else echo "No recordings found in $RECORDINGS_DIR" fi - + # Post the comment gh pr comment "${PR_NUMBER}" --repo "${{ github.repository }}" --body "$COMMENT_BODY" echo "Posted comment to PR #${PR_NUMBER}" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 19627b4e58a..2c2b9b8f419 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -39,6 +39,11 @@ on: required: false type: boolean default: false + # Controls whether to set CLI E2E environment variables (GH_TOKEN, GITHUB_PR_NUMBER, GITHUB_PR_HEAD_SHA) + requiresCliArchive: + required: false + type: boolean + default: false # Controls whether to install Playwright browsers during project build enablePlaywrightInstall: required: false @@ -234,7 +239,8 @@ jobs: run: > ${{ env.BUILD_SCRIPT }} -restore -ci -build -projects ${{ env.TEST_PROJECT_PATH }} /p:PrepareForHelix=true - /bl:${{ github.workspace }}/artifacts/log/Debug/PrepareForHelix.binlog + /p:ArchiveTests=true + /bl:${{ github.workspace }}/artifacts/log/Debug/BuildAndArchive.binlog ${{ !inputs.enablePlaywrightInstall && '/p:InstallBrowsersForPlaywright=false' || '' }} ${{ inputs.versionOverrideArg }} @@ -296,6 +302,10 @@ jobs: TEST_LOG_PATH: ${{ github.workspace }}/artifacts/log/test-logs TestsRunningOutsideOfRepo: true TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io' + # PR metadata and token for CLI E2E tests that download artifacts from a PR + GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} + GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} + GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background ${{ github.workspace }}/${{ env.DOTNET_SCRIPT }} ${{ github.workspace }}/tools/scripts/Heartbeat.cs & @@ -334,6 +344,10 @@ jobs: TEST_LOG_PATH: ${{ github.workspace }}/artifacts/log/test-logs TestsRunningOutsideOfRepo: true TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io' + # PR metadata and token for CLI E2E tests that download artifacts from a PR + GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} + GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} + GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background (output goes to console directly) $heartbeatProcess = Start-Process -FilePath "dotnet" ` @@ -374,12 +388,10 @@ jobs: NUGET_PACKAGES: ${{ github.workspace }}/.packages PLAYWRIGHT_INSTALLED: ${{ !inputs.enablePlaywrightInstall && 'false' || 'true' }} TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io' - # PR number for CLI E2E tests that download artifacts from a PR - GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} - # PR head SHA for version verification (not the merge commit SHA) - GITHUB_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - # GitHub token for CLI E2E tests to download artifacts - GH_TOKEN: ${{ github.token }} + # PR metadata and token for CLI E2E tests that download artifacts from a PR + GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} + GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} + GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background ${{ env.DOTNET_SCRIPT }} ${{ github.workspace }}/tools/scripts/Heartbeat.cs & @@ -418,12 +430,10 @@ jobs: NUGET_PACKAGES: ${{ github.workspace }}/.packages PLAYWRIGHT_INSTALLED: ${{ !inputs.enablePlaywrightInstall && 'false' || 'true' }} TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io' - # PR number for CLI E2E tests that download artifacts from a PR - GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} - # PR head SHA for version verification (not the merge commit SHA) - GITHUB_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - # GitHub token for CLI E2E tests to download artifacts - GH_TOKEN: ${{ github.token }} + # PR metadata and token for CLI E2E tests that download artifacts from a PR + GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} + GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} + GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background (output goes to console directly) $heartbeatProcess = Start-Process -FilePath "${{ env.DOTNET_SCRIPT }}" ` diff --git a/.github/workflows/specialized-test-runner.yml b/.github/workflows/specialized-test-runner.yml index 434f8e5442f..2adf4776930 100644 --- a/.github/workflows/specialized-test-runner.yml +++ b/.github/workflows/specialized-test-runner.yml @@ -102,7 +102,7 @@ jobs: uses: ./.github/workflows/build-packages.yml run_tests: - name: Test + name: ${{ matrix.tests.project }} needs: [generate_tests_matrix, build_packages] strategy: fail-fast: false diff --git a/.github/workflows/tests-runner.yml b/.github/workflows/tests-runner.yml deleted file mode 100644 index c18a1106f96..00000000000 --- a/.github/workflows/tests-runner.yml +++ /dev/null @@ -1,206 +0,0 @@ -# Executes all the tests on all the platforms -name: Tests - -on: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - # Duplicated jobs so their dependencies are not blocked on both the - # setup jobs - - # Generates a runsheet for all the tests in the solution that do not require - # NuGet packages to be built. - # The runsheet generation is expected to be fast. - generate_tests_matrix: - name: Generate test runsheet - runs-on: windows-latest - outputs: - runsheet: ${{ steps.generate_tests_matrix.outputs.runsheet }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - # In order to build the solution, we need to install the SDK and the toolsets first - # as defined in global.json. For this, create a temporary project file and run the - # build command with the -restore option. This will install the SDK and toolsets. - # - # We don't want to run 'build.cmd -restore' as it will also restore all the packages, - # which takes a long time and is not needed for this job. - - name: Install toolsets - shell: pwsh - run: | - mkdir ./artifacts/tmp -force | Out-Null - '' | Out-File -FilePath ./artifacts/tmp/install-toolset.proj -Encoding utf8 - ./build.cmd -restore -projects ./artifacts/tmp/install-toolset.proj - - - name: Generate test runsheet - id: generate_tests_matrix - shell: pwsh - run: | - ./build.cmd -test /p:TestRunnerName=TestRunsheetBuilder -bl -c Release - - - name: Upload logs, and test results - if: ${{ always() }} - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: runsheet-logs - path: | - ${{ github.workspace }}/artifacts/log/*/*.binlog - ${{ github.workspace }}/artifacts/log/*/TestLogs/** - ${{ github.workspace }}/artifacts/tmp/*/combined_runsheet.json - retention-days: 3 - - # Generates a runsheet for all the tests in the solution that DO require - # NuGet packages to be built. - # The runsheet generation is expected to be slow as we need to restore and build - # the whole solution and publish all the packages. - generate_e2e_matrix: - name: Generate E2E test runsheet - runs-on: windows-latest - outputs: - runsheet: ${{ steps.generate_e2e_matrix.outputs.runsheet }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Build with packages - run: | - ./build.cmd -restore -build -pack -c Release -ci -bl /p:InstallBrowsersForPlaywright=false /p:SkipTestProjects=true - - - name: Generate test runsheet - id: generate_e2e_matrix - run: | - ./build.cmd -test /p:TestRunnerName=TestRunsheetBuilder /p:FullE2e=true -bl -c Release - - - name: Upload built NuGets - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: built-nugets - path: artifacts/packages - retention-days: 3 - - - name: Upload logs, and test results - if: ${{ always() }} - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: runsheet-e2e-logs - path: | - ${{ github.workspace }}/artifacts/log/*/*.binlog - ${{ github.workspace }}/artifacts/log/*/TestLogs/** - ${{ github.workspace }}/artifacts/tmp/*/combined_runsheet.json - retention-days: 3 - - run_tests: - name: Test - needs: generate_tests_matrix - strategy: - fail-fast: false - matrix: - tests: ${{ fromJson(needs.generate_tests_matrix.outputs.runsheet) }} - - runs-on: ${{ matrix.tests.os }} # Use the OS from the matrix - - steps: - - name: Trust HTTPS development certificate - if: runner.os == 'Linux' - run: dotnet dev-certs https --trust - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Verify Docker is running - # nested docker containers not supported on windows - if: runner.os == 'Linux' - run: docker info - - - name: Install Azure Functions Core Tools - if: runner.os == 'Linux' - run: | - npm i -g azure-functions-core-tools@4 --unsafe-perm true - - - name: Test ${{ matrix.tests.label }} - run: | - ${{ matrix.tests.command }} - - - name: Upload test results - if: always() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: ${{ matrix.tests.project }}-${{ matrix.tests.os }}-logs - path: | - ${{ github.workspace }}/artifacts/TestResults/*/*.trx - ${{ github.workspace }}/artifacts/log/*/TestLogs/** - retention-days: 30 - - - name: Upload logs - if: failure() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: ${{ matrix.tests.project }}-${{ matrix.tests.os }}-binlogs - path: | - ${{ github.workspace }}/artifacts/log/*/*.binlog - retention-days: 3 - - run_e2e_tests: - name: E2ETest - needs: generate_e2e_matrix - strategy: - fail-fast: false - matrix: - tests: ${{ fromJson(needs.generate_e2e_matrix.outputs.runsheet) }} - - runs-on: ${{ matrix.tests.os }} # Use the OS from the matrix - - steps: - - name: Trust HTTPS development certificate - if: runner.os == 'Linux' - run: dotnet dev-certs https --trust - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Download built NuGets - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 - with: - pattern: built-nugets - path: ${{ github.workspace }}/artifacts/packages - - - name: Copy NuGets to the correct location - shell: pwsh - run: - Move-Item -Path "${{ github.workspace }}/artifacts/packages/built-nugets/Release" -Destination "${{ github.workspace }}/artifacts/packages" - - - name: Verify Docker is running - # nested docker containers not supported on windows - if: runner.os == 'Linux' - run: docker info - - - name: Install Azure Functions Core Tools - if: runner.os == 'Linux' - run: | - npm i -g azure-functions-core-tools@4 --unsafe-perm true - - - name: Test ${{ matrix.tests.label }} - env: - BUILT_NUGETS_PATH: ${{ github.workspace }}/artifacts/packages/Release/Shipping - run: | - ${{ matrix.tests.command }} - - - name: Upload test results - if: always() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: ${{ matrix.tests.project }}-${{ matrix.tests.os }}-logs - path: | - ${{ github.workspace }}/artifacts/TestResults/*/*.trx - ${{ github.workspace }}/artifacts/log/*/TestLogs/** - retention-days: 30 - - - name: Upload logs - if: failure() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: ${{ matrix.tests.project }}-${{ matrix.tests.os }}-binlogs - path: | - ${{ github.workspace }}/artifacts/log/*/*.binlog - retention-days: 3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eab7145d481..4d8c2aa0311 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,55 +9,28 @@ on: type: string jobs: - # Duplicated jobs so their dependencies are not blocked on both the - # setup jobs - - setup_for_tests_lin: - name: Setup for tests (Linux) + setup_for_tests: + name: Setup for tests runs-on: ubuntu-latest outputs: - integrations_tests_matrix: ${{ steps.generate_tests_matrix.outputs.integrations_tests_matrix }} - templates_tests_matrix: ${{ steps.generate_tests_matrix.outputs.templates_tests_matrix }} - cli_e2e_tests_matrix: ${{ steps.generate_tests_matrix.outputs.cli_e2e_tests_matrix }} + tests_matrix_no_nugets: ${{ steps.split_matrix.outputs.tests_matrix_no_nugets }} + tests_matrix_no_nugets_overflow: ${{ steps.split_matrix.outputs.tests_matrix_no_nugets_overflow }} + tests_matrix_requires_nugets: ${{ steps.split_matrix.outputs.tests_matrix_requires_nugets }} + tests_matrix_requires_cli_archive: ${{ steps.split_matrix.outputs.tests_matrix_requires_cli_archive }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/enumerate-tests id: generate_tests_matrix with: - includeIntegrations: true - includeTemplates: true - includeCliE2E: ${{ github.event_name == 'pull_request' }} - - setup_for_tests_macos: - name: Setup for tests (macOS) - runs-on: macos-latest - outputs: - integrations_tests_matrix: ${{ steps.generate_tests_matrix.outputs.integrations_tests_matrix }} - templates_tests_matrix: ${{ steps.generate_tests_matrix.outputs.templates_tests_matrix }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - uses: ./.github/actions/enumerate-tests - id: generate_tests_matrix - with: - includeIntegrations: true - includeTemplates: true - - setup_for_tests_win: - name: Setup for tests (Windows) - runs-on: windows-latest - outputs: - integrations_tests_matrix: ${{ steps.generate_tests_matrix.outputs.integrations_tests_matrix }} - templates_tests_matrix: ${{ steps.generate_tests_matrix.outputs.templates_tests_matrix }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + buildArgs: '/p:IncludeTemplateTests=true /p:IncludeCliE2ETests=${{ github.event_name == ''pull_request'' }}' - - uses: ./.github/actions/enumerate-tests - id: generate_tests_matrix - with: - includeIntegrations: true - includeTemplates: true + - name: Split matrix by dependency type + id: split_matrix + shell: pwsh + run: | + $splitScript = "${{ github.workspace }}/eng/scripts/split-test-matrix-by-deps.ps1" + & $splitScript -AllTestsMatrix '${{ steps.generate_tests_matrix.outputs.all_tests }}' -OutputToGitHubEnv build_packages: name: Build packages @@ -72,133 +45,82 @@ jobs: with: versionOverrideArg: ${{ inputs.versionOverrideArg }} - integrations_test_lin: - uses: ./.github/workflows/run-tests.yml - name: Integrations Linux - needs: setup_for_tests_lin - strategy: - fail-fast: false - matrix: - ${{ fromJson(needs.setup_for_tests_lin.outputs.integrations_tests_matrix) }} - with: - testShortName: ${{ matrix.shortname }} - os: "ubuntu-latest" - # Docker tests are run on linux, and Hosting tests take longer to finish - testSessionTimeout: ${{ matrix.shortname == 'Hosting' && '25m' || '15m' }} - extraTestArgs: "--filter-not-trait \"quarantined=true\" --filter-not-trait \"outerloop=true\"" - versionOverrideArg: ${{ inputs.versionOverrideArg }} - - integrations_test_macos: - uses: ./.github/workflows/run-tests.yml - name: Integrations macos - needs: setup_for_tests_macos - strategy: - fail-fast: false - matrix: - ${{ fromJson(needs.setup_for_tests_macos.outputs.integrations_tests_matrix) }} - with: - testShortName: ${{ matrix.shortname }} - os: "macos-latest" - extraTestArgs: "--filter-not-trait \"quarantined=true\" --filter-not-trait \"outerloop=true\"" - versionOverrideArg: ${{ inputs.versionOverrideArg }} - - integrations_test_win: - uses: ./.github/workflows/run-tests.yml - name: Integrations Windows - needs: setup_for_tests_win - strategy: - fail-fast: false - matrix: - ${{ fromJson(needs.setup_for_tests_win.outputs.integrations_tests_matrix) }} - with: - testShortName: ${{ matrix.shortname }} - os: "windows-latest" - extraTestArgs: "--filter-not-trait \"quarantined=true\" --filter-not-trait \"outerloop=true\"" - versionOverrideArg: ${{ inputs.versionOverrideArg }} - - templates_test_lin: - name: Templates Linux + tests_no_nugets: + name: ${{ matrix.shortname }} uses: ./.github/workflows/run-tests.yml - needs: [setup_for_tests_lin, build_packages] + needs: setup_for_tests + if: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_no_nugets).include[0] != null }} strategy: fail-fast: false - matrix: ${{ fromJson(needs.setup_for_tests_lin.outputs.templates_tests_matrix) }} + matrix: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_no_nugets) }} with: testShortName: ${{ matrix.shortname }} - os: "ubuntu-latest" - testProjectPath: tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj - testSessionTimeout: 20m - testHangTimeout: "12m" - extraTestArgs: "--filter-not-trait quarantined=true --filter-not-trait outerloop=true --filter-class Aspire.Templates.Tests.${{ matrix.shortname }}" + os: ${{ matrix.runs-on }} + testProjectPath: ${{ matrix.testProjectPath }} + testSessionTimeout: ${{ matrix.testSessionTimeout }} + testHangTimeout: ${{ matrix.testHangTimeout }} + extraTestArgs: "--filter-not-trait \"quarantined=true\" --filter-not-trait \"outerloop=true\" ${{ matrix.extraTestArgs }}" versionOverrideArg: ${{ inputs.versionOverrideArg }} - requiresNugets: true - requiresTestSdk: true + requiresNugets: ${{ matrix.requiresNugets }} + requiresTestSdk: ${{ matrix.requiresTestSdk }} - templates_test_macos: - name: Templates macos + tests_no_nugets_overflow: + name: ${{ matrix.shortname }} uses: ./.github/workflows/run-tests.yml - needs: [setup_for_tests_macos, build_packages] + needs: setup_for_tests + if: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_no_nugets_overflow).include[0] != null }} strategy: fail-fast: false - matrix: ${{ fromJson(needs.setup_for_tests_macos.outputs.templates_tests_matrix) }} + matrix: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_no_nugets_overflow) }} with: testShortName: ${{ matrix.shortname }} - os: "macos-latest" - testProjectPath: tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj - testSessionTimeout: 20m - testHangTimeout: "12m" - extraTestArgs: "--filter-not-trait quarantined=true --filter-not-trait outerloop=true --filter-class Aspire.Templates.Tests.${{ matrix.shortname }}" + os: ${{ matrix.runs-on }} + testProjectPath: ${{ matrix.testProjectPath }} + testSessionTimeout: ${{ matrix.testSessionTimeout }} + testHangTimeout: ${{ matrix.testHangTimeout }} + extraTestArgs: "--filter-not-trait \"quarantined=true\" --filter-not-trait \"outerloop=true\" ${{ matrix.extraTestArgs }}" versionOverrideArg: ${{ inputs.versionOverrideArg }} - requiresNugets: true - requiresTestSdk: true + requiresNugets: ${{ matrix.requiresNugets }} + requiresTestSdk: ${{ matrix.requiresTestSdk }} - templates_test_win: - name: Templates Windows + tests_requires_nugets: + name: ${{ matrix.shortname }} uses: ./.github/workflows/run-tests.yml - needs: [setup_for_tests_win, build_packages] + needs: [setup_for_tests, build_packages] + if: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets).include[0] != null }} strategy: fail-fast: false - matrix: ${{ fromJson(needs.setup_for_tests_win.outputs.templates_tests_matrix) }} + matrix: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets) }} with: testShortName: ${{ matrix.shortname }} - os: "windows-latest" - testProjectPath: tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj - testSessionTimeout: 20m - testHangTimeout: "12m" - extraTestArgs: "--filter-not-trait quarantined=true --filter-not-trait outerloop=true --filter-class Aspire.Templates.Tests.${{ matrix.shortname }}" - versionOverrideArg: ${{ inputs.versionOverrideArg }} - requiresNugets: true - requiresTestSdk: true - - endtoend_tests: - name: EndToEnd Linux - uses: ./.github/workflows/run-tests.yml - needs: build_packages - with: - testShortName: EndToEnd - # EndToEnd is not run on Windows/macOS due to missing Docker support - os: ubuntu-latest - testProjectPath: tests/Aspire.EndToEnd.Tests/Aspire.EndToEnd.Tests.csproj - requiresNugets: true + os: ${{ matrix.runs-on }} + testProjectPath: ${{ matrix.testProjectPath }} + testSessionTimeout: ${{ matrix.testSessionTimeout }} + testHangTimeout: ${{ matrix.testHangTimeout }} + extraTestArgs: "--filter-not-trait \"quarantined=true\" --filter-not-trait \"outerloop=true\" ${{ matrix.extraTestArgs }}" versionOverrideArg: ${{ inputs.versionOverrideArg }} + requiresNugets: ${{ matrix.requiresNugets }} + requiresTestSdk: ${{ matrix.requiresTestSdk }} - cli_e2e_tests: - name: Cli E2E Linux - # Only run CLI E2E tests during PR builds - if: ${{ github.event_name == 'pull_request' }} + tests_requires_cli_archive: + name: ${{ matrix.shortname }} uses: ./.github/workflows/run-tests.yml - needs: [setup_for_tests_lin, build_packages, build_cli_archives] + needs: [setup_for_tests, build_packages, build_cli_archives] + if: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null }} strategy: fail-fast: false - matrix: ${{ fromJson(needs.setup_for_tests_lin.outputs.cli_e2e_tests_matrix) }} + matrix: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive) }} with: testShortName: ${{ matrix.shortname }} - os: "ubuntu-latest" - testProjectPath: tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj - testSessionTimeout: 20m - testHangTimeout: "12m" - extraTestArgs: "--filter-not-trait quarantined=true --filter-not-trait outerloop=true --filter-class Aspire.Cli.EndToEnd.Tests.${{ matrix.shortname }}" + os: ${{ matrix.runs-on }} + testProjectPath: ${{ matrix.testProjectPath }} + testSessionTimeout: ${{ matrix.testSessionTimeout }} + testHangTimeout: ${{ matrix.testHangTimeout }} + extraTestArgs: "--filter-not-trait \"quarantined=true\" --filter-not-trait \"outerloop=true\" ${{ matrix.extraTestArgs }}" versionOverrideArg: ${{ inputs.versionOverrideArg }} + requiresNugets: ${{ matrix.requiresNugets }} + requiresTestSdk: ${{ matrix.requiresTestSdk }} + requiresCliArchive: true polyglot_validation: name: Polyglot SDK Validation @@ -219,7 +141,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v2 with: - node-version: ${{ matrix.node-version }} + node-version: '20.x' - name: Install dependencies run: npm install - name: Run tests @@ -237,17 +159,12 @@ jobs: runs-on: ubuntu-latest name: Final Test Results needs: [ - build_cli_archives, - cli_e2e_tests, - endtoend_tests, extension_tests_win, - integrations_test_lin, - integrations_test_macos, - integrations_test_win, + tests_no_nugets, + tests_no_nugets_overflow, + tests_requires_nugets, + tests_requires_cli_archive, polyglot_validation, - templates_test_lin, - templates_test_macos, - templates_test_win ] steps: - name: Checkout code @@ -290,23 +207,24 @@ jobs: - name: Fail if any dependency failed # 'skipped' can be when a transitive dependency fails and the dependent job gets 'skipped'. - # For example, one of setup_* jobs failing and the Integration test jobs getting 'skipped' - # Note: cli_e2e_tests is intentionally skipped on non-PR builds, so we check it separately + # For example, one of setup_* jobs failing and the dependent test jobs getting 'skipped'. + # Overflow jobs are intentionally skipped when below the overflow threshold, so we + # check specific job names rather than using contains(needs.*.result, 'skipped'). if: >- ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || - (github.event_name == 'pull_request' && contains(needs.*.result, 'skipped')) || + (github.event_name == 'pull_request' && + (needs.extension_tests_win.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets.result == 'skipped' || + needs.tests_requires_cli_archive.result == 'skipped' || + needs.polyglot_validation.result == 'skipped')) || (github.event_name != 'pull_request' && - (needs.endtoend_tests.result == 'skipped' || - needs.extension_tests_win.result == 'skipped' || - needs.integrations_test_lin.result == 'skipped' || - needs.integrations_test_macos.result == 'skipped' || - needs.integrations_test_win.result == 'skipped' || - needs.polyglot_validation.result == 'skipped' || - needs.templates_test_lin.result == 'skipped' || - needs.templates_test_macos.result == 'skipped' || - needs.templates_test_win.result == 'skipped'))) }} + (needs.extension_tests_win.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets.result == 'skipped' || + needs.polyglot_validation.result == 'skipped'))) }} run: | echo "One or more dependent jobs failed." exit 1 diff --git a/docs/ci/TestingOnCI.md b/docs/ci/TestingOnCI.md new file mode 100644 index 00000000000..6713a9456ed --- /dev/null +++ b/docs/ci/TestingOnCI.md @@ -0,0 +1,300 @@ +# Testing on CI + +This document describes the test infrastructure for CI pipelines in the Aspire repository. The infrastructure is designed to be platform-agnostic, with a canonical matrix format that can be consumed by GitHub Actions, Azure DevOps, or other CI systems. + +## Overview + +The CI test infrastructure uses a unified matrix generation system that: + +1. Enumerates all test projects and their metadata +2. Optionally splits large test projects into parallel jobs +3. Generates a canonical test matrix (platform-agnostic) +4. Expands the matrix for specific CI platforms (GitHub Actions, Azure DevOps) +5. Runs tests in parallel across multiple operating systems + +## Architecture + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ MSBuild Phase │ +│ (TestEnumerationRunsheetBuilder.targets + build-test-matrix.ps1) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ .tests- │ │ .tests- │ │ canonical-test- │ │ +│ │ metadata.json│ ─► │ partitions. │ ─► │ matrix.json │ │ +│ │ (per project)│ │ json (split) │ │ (canonical format) │ │ +│ └──────────────┘ └──────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Platform-Specific Expansion │ +│ │ +│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ +│ │ expand-test-matrix- │ │ (future) │ │ +│ │ github.ps1 │ │ expand-test-matrix- │ │ +│ │ • OS → runner mapping │ │ azdo.ps1 │ │ +│ │ • { "include": [...] } │ │ • OS → vmImage mapping │ │ +│ └─────────────────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Test Matrix Generation Flow + +### Phase 1: Test Enumeration + +The `enumerate-tests` GitHub Action (`.github/actions/enumerate-tests/action.yml`) triggers a special build: + +```bash +./build.sh -test /p:TestRunnerName=TestEnumerationRunsheetBuilder +``` + +This invokes `eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets` for each test project, which: + +- Checks OS compatibility via `RunOnGithubActions{Linux/Windows/MacOS}` properties +- Determines if the project uses test splitting (`SplitTestsOnCI` property) +- Writes a `.tests-metadata.json` file to `artifacts/helix/` containing: + - `projectName`, `shortName`, `testProjectPath` + - `supportedOSes` array (e.g., `["windows", "linux", "macos"]`) + - `requiresNugets`, `requiresTestSdk`, `requiresCliArchive` flags + - `enablePlaywrightInstall` flag + - `testSessionTimeout`, `testHangTimeout` values + - `uncollectedTestsSessionTimeout`, `uncollectedTestsHangTimeout` values + - `splitTests` flag + +### Phase 2: Test Partition Discovery + +For projects with `SplitTestsOnCI=true`, the `GenerateTestPartitionsForCI` target runs `eng/scripts/split-test-projects-for-ci.ps1`, which: + +1. **Attempts partition extraction**: Uses `tools/ExtractTestPartitions` to scan the test assembly for `[Trait("Partition", "name")]` attributes on test classes +2. **If partitions found**: Writes entries like `collection:PartitionName` plus `uncollected:*` (safety net for tests without partition traits). Note: the term "collection" here refers to partition groups, not xUnit `[Collection]` attributes which serve a different purpose (shared test fixtures). +3. **If no partitions found**: Falls back to class-based splitting using `--list-tests` output, writing entries like `class:Namespace.ClassName` + +Output: `.tests-partitions.json` file alongside the metadata file. + +### Phase 3: Canonical Matrix Generation + +After all projects build, `eng/AfterSolutionBuild.targets` runs `eng/scripts/build-test-matrix.ps1`, which: + +1. Collects all `.tests-metadata.json` files +2. For split test projects, reads the corresponding `.tests-partitions.json` +3. Applies default values for missing properties +4. Normalizes boolean values +5. Creates matrix entries: + - **Regular tests**: One entry per project + - **Partition-based splits**: One entry per partition + one for `uncollected:*` + - **Class-based splits**: One entry per test class +6. Outputs `artifacts/canonical-test-matrix.json` in canonical format (flat array with `requiresNugets`, `requiresCliArchive` booleans per entry) + +**Canonical format:** +```json +{ + "tests": [ + { + "name": "Templates-StarterTests", + "shortname": "Templates-StarterTests", + "testProjectPath": "tests/Aspire.Templates.Tests/...", + "supportedOSes": ["windows", "linux", "macos"], + "requiresNugets": true, + "requiresTestSdk": true, + "testSessionTimeout": "20m", + "testHangTimeout": "10m", + "extraTestArgs": "--filter-class \"...\"" + }, + { + "name": "Hosting-Docker", + "shortname": "Hosting-Docker", + "testProjectPath": "tests/Aspire.Hosting.Tests/...", + "supportedOSes": ["linux"], + "requiresNugets": false, + "testSessionTimeout": "30m", + "extraTestArgs": "--filter-trait \"Partition=Docker\"" + } + ] +} +``` + +### Phase 4: Platform-Specific Expansion + +Each CI platform has a thin script that transforms the canonical matrix: + +**GitHub Actions** (`eng/scripts/expand-test-matrix-github.ps1`): +- Expands each entry for every OS in its `supportedOSes` array +- Maps OS names to GitHub runners (`linux` → `ubuntu-latest`, etc.) +- Splits entries into categories by dependency requirements: + - `no_nugets` — tests with no package dependencies + - `requires_nugets` — tests needing built NuGet packages + - `requires_cli_archive` — tests needing native CLI archives +- Applies overflow splitting for the `no_nugets` category (threshold: 250 entries) to stay under the GitHub Actions 256-job-per-matrix limit +- Outputs 4 GitHub Actions matrices: `no_nugets` (primary), `no_nugets_overflow`, `requires_nugets`, `requires_cli_archive` + +**Azure DevOps** (future): +- Would map OS names to vmImage or pool names +- Would output Azure DevOps matrix format: `{ ConfigName: { vars } }` + +This separation keeps 90% of the logic platform-agnostic while allowing each CI system to use its native matrix format. + +### Phase 5: Test Execution + +In `.github/workflows/tests.yml`, the workflow: + +1. Receives 4 pre-split matrices from the `enumerate-tests` action (split by `expand-test-matrix-github.ps1`) +2. Runs 4 job groups using the split matrices: + - `tests_no_nugets`: Runs immediately after enumeration + - `tests_no_nugets_overflow`: Runs immediately (handles entries beyond the 250-entry threshold) + - `tests_requires_nugets`: Waits for `build_packages` job + - `tests_requires_cli_archive`: Waits for both `build_packages` and `build_cli_archives` jobs + +Each job invokes `.github/workflows/run-tests.yml` with matrix parameters including `extraTestArgs` for filtering (e.g., `--filter-trait "Partition=X"`). + +> **Note:** The workflow automatically prepends `--filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"` before any `extraTestArgs`, ensuring quarantined and outerloop tests are always excluded from the main test run. + +#### GitHub Actions 256-Job Limit + +GitHub Actions enforces a maximum of 256 jobs per `strategy.matrix`. To stay within this limit, the `no_nugets` category (typically the largest) is split into primary and overflow buckets at a threshold of 250 entries. If the total entry count is below 250, the overflow matrix is empty and the overflow job is skipped. + +## Enabling Test Splitting for a Project + +To split a test project into parallel CI jobs, add these properties to the `.csproj`: + +```xml + + + true + Aspire.YourProject.Tests + + + 30m + 15m + 15m + 10m + +``` + +### Using Partition Traits (Recommended) + +For explicit control over test grouping, add `[Trait("Partition", "name")]` to test classes: + +```csharp +[Trait("Partition", "Docker")] +public class DockerResourceTests +{ + // Tests that require Docker +} + +[Trait("Partition", "Publishing")] +public class PublishingTests +{ + // Publishing-related tests +} +``` + +Tests without a `Partition` trait run in a separate `uncollected` job, ensuring nothing is missed. + +### Class-Based Splitting (Fallback) + +If no `Partition` traits are found, the infrastructure automatically falls back to class-based splitting, creating one CI job per test class. This is less efficient but requires no code changes. + +## Controlling OS Compatibility + +By default, tests run on all three platforms. To restrict a project to specific OSes: + +```xml + + + false + true + false + +``` + +## Requiring NuGet Packages + +For tests that need the built Aspire packages (e.g., template tests, end-to-end tests): + +```xml + + true + + true + +``` + +These tests wait for the `build_packages` job before running. + +## Requiring CLI Native Archives + +For tests that need native CLI archives (e.g., CLI end-to-end tests): + +```xml + + true + true + +``` + +These tests wait for both the `build_packages` and `build_cli_archives` jobs before running. The workflow also sets `GH_TOKEN`, `GITHUB_PR_NUMBER`, and `GITHUB_PR_HEAD_SHA` environment variables for CLI E2E test scenarios. + +## Enabling Playwright + +For tests that require Playwright browser automation: + +```xml + + true + +``` + +This flag is tracked in the test metadata and controls whether Playwright browsers are installed during the test build step. + +## Deployment Tests + +Deployment end-to-end tests have a separate flow from the standard test matrix: + +1. The `enumerate-tests` action builds the deployment test project with `GenerateTestPartitionsForCI` +2. A separate `generate_deployment_matrix` step reads the generated partitions +3. The deployment matrix is output independently from the main test matrices +4. Deployment tests run in a dedicated workflow (`tests-deployment.yml`) with Azure credentials + +## File Artifacts + +During enumeration, these files are generated in `artifacts/`: + +| File | Description | +|------|-------------| +| `helix/.tests-metadata.json` | Project metadata (OS support, timeouts, flags) | +| `helix/.tests-partitions.json` | Partition/class list for split projects | +| `canonical-test-matrix.json` | Canonical matrix (platform-agnostic) | + +## Scripts Reference + +| Script | Purpose | +|--------|---------| +| `eng/scripts/build-test-matrix.ps1` | Generates canonical matrix from metadata files | +| `eng/scripts/expand-test-matrix-github.ps1` | Expands canonical matrix for GitHub Actions | +| `eng/scripts/split-test-projects-for-ci.ps1` | Discovers test partitions/classes for splitting | + +## Debugging Test Enumeration + +To run enumeration locally and inspect the generated matrix: + +```bash +./build.sh -test \ + /p:TestRunnerName=TestEnumerationRunsheetBuilder \ + /p:TestMatrixOutputPath=artifacts/canonical-test-matrix.json \ + /p:IncludeTemplateTests=true \ + /p:GenerateCIPartitions=true +``` + +Then inspect: +- `artifacts/helix/*.tests-metadata.json` for per-project metadata +- `artifacts/helix/*.tests-partitions.json` for split test entries +- `artifacts/canonical-test-matrix.json` for the canonical matrix + +To test GitHub-specific expansion locally: + +```powershell +pwsh eng/scripts/expand-test-matrix-github.ps1 ` + -CanonicalMatrixFile artifacts/canonical-test-matrix.json ` + -OutputMatrixFile artifacts/github-matrix.json +``` diff --git a/eng/AfterSolutionBuild.targets b/eng/AfterSolutionBuild.targets index 3288a591e3d..411f444dd5e 100644 --- a/eng/AfterSolutionBuild.targets +++ b/eng/AfterSolutionBuild.targets @@ -50,7 +50,7 @@ ``` --> - + <_CombinedRunsheetFile>$(ArtifactsTmpDir)/combined_runsheet.json <_Command> @@ -105,4 +105,26 @@ + + + + + + + <_TestMatrixOutputPath>$([MSBuild]::NormalizePath('$(RepoRoot)', '$(TestMatrixOutputPath)')) + <_BuildMatrixScript>$([MSBuild]::NormalizePath($(RepoRoot), 'eng', 'scripts', 'build-test-matrix.ps1')) + + + + + + + + diff --git a/eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets b/eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets new file mode 100644 index 00000000000..849b1a73b9f --- /dev/null +++ b/eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets @@ -0,0 +1,122 @@ + + + + + + <_NormalizedProjectDirectory>$([System.String]::Copy('$(MSBuildProjectDirectory)').Replace('\','/')) + + <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and $(_NormalizedProjectDirectory.Contains('tests/Shared'))">true + <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and $(_NormalizedProjectDirectory.Contains('tests/testproject'))">true + <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and $(_NormalizedProjectDirectory.Contains('tests/TestingAppHost1'))">true + + <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and '$(IncludeTemplateTests)' != 'true' and '$(MSBuildProjectName)' == 'Aspire.Templates.Tests'">true + <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and '$(IncludeCliE2ETests)' != 'true' and '$(MSBuildProjectName)' == 'Aspire.Cli.EndToEnd.Tests'">true + + + <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and '$(OnlyDeploymentTests)' != 'true' and '$(MSBuildProjectName)' == 'Aspire.Deployment.EndToEnd.Tests'">true + + <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and '$(OnlyDeploymentTests)' == 'true' and '$(MSBuildProjectName)' != 'Aspire.Deployment.EndToEnd.Tests'">true + + <_ShouldSkipProject Condition="'$(_ShouldSkipProject)' == '' and '$(SkipTests)' == 'true'">true + + + + + + + + + + + <_RelativeProjectPath>$([System.String]::Copy('$(MSBuildProjectFullPath)').Replace('$(RepoRoot)', '')) + + <_RelativeProjectPath Condition="$(_RelativeProjectPath.StartsWith('/')) or $(_RelativeProjectPath.StartsWith('\\'))">$(_RelativeProjectPath.Substring(1)) + + + <_RequiresNugets>false + <_RequiresNugets Condition="'$(RequiresNugets)' == 'true'">true + + + <_RequiresTestSdk>false + <_RequiresTestSdk Condition="'$(RequiresTestSdk)' == 'true'">true + + + <_RequiresCliArchive>false + <_RequiresCliArchive Condition="'$(RequiresCliArchive)' == 'true'">true + + + <_EnablePlaywrightInstall>false + <_EnablePlaywrightInstall Condition="'$(EnablePlaywrightInstall)' == 'true'">true + + + <_SupportedOSesJson /> + <_SupportedOSesJson Condition="'$(RunOnGithubActionsWindows)' == 'true'">$(_SupportedOSesJson)"windows", + <_SupportedOSesJson Condition="'$(RunOnGithubActionsLinux)' == 'true'">$(_SupportedOSesJson)"linux", + <_SupportedOSesJson Condition="'$(RunOnGithubActionsMacOS)' == 'true'">$(_SupportedOSesJson)"macos", + + <_SupportedOSesJson Condition="'$(_SupportedOSesJson)' != ''">$(_SupportedOSesJson.TrimEnd(',')) + + + <_TestSessionTimeout Condition="'$(TestSessionTimeout)' != ''">$(TestSessionTimeout) + <_TestSessionTimeout Condition="'$(TestSessionTimeout)' == ''">20m + <_TestHangTimeout Condition="'$(TestHangTimeout)' != ''">$(TestHangTimeout) + <_TestHangTimeout Condition="'$(TestHangTimeout)' == ''">10m + <_UncollectedTestsSessionTimeout Condition="'$(UncollectedTestsSessionTimeout)' != ''">$(UncollectedTestsSessionTimeout) + <_UncollectedTestsSessionTimeout Condition="'$(UncollectedTestsSessionTimeout)' == ''">15m + <_UncollectedTestsHangTimeout Condition="'$(UncollectedTestsHangTimeout)' != ''">$(UncollectedTestsHangTimeout) + <_UncollectedTestsHangTimeout Condition="'$(UncollectedTestsHangTimeout)' == ''">10m + + + <_MetadataJson>{ + "projectName": "$(MSBuildProjectName)", + "shortName": "$(_ShortName)", + "testClassNamesPrefix": "$(MSBuildProjectName)", + "testProjectPath": "$(_RelativeProjectPath)", + "requiresNugets": "$(_RequiresNugets.ToLowerInvariant())", + "requiresTestSdk": "$(_RequiresTestSdk.ToLowerInvariant())", + "requiresCliArchive": "$(_RequiresCliArchive.ToLowerInvariant())", + "enablePlaywrightInstall": "$(_EnablePlaywrightInstall.ToLowerInvariant())", + "splitTests": "$(SplitTestsOnCI)", + "supportedOSes": [$(_SupportedOSesJson)], + "testSessionTimeout": "$(_TestSessionTimeout)", + "testHangTimeout": "$(_TestHangTimeout)", + "uncollectedTestsSessionTimeout": "$(_UncollectedTestsSessionTimeout)", + "uncollectedTestsHangTimeout": "$(_UncollectedTestsHangTimeout)" +} + + + <_MetadataOutputDir Condition="'$(TestArchiveTestsDir)' != ''">$(TestArchiveTestsDir) + <_MetadataOutputDir Condition="'$(TestArchiveTestsDir)' == ''">$([MSBuild]::NormalizeDirectory($(ArtifactsDir), 'helix')) + <_MetadataFile>$(_MetadataOutputDir)$(MSBuildProjectName).tests-metadata.json + + + + + + + + + + diff --git a/eng/TestRunsheetBuilder/TestRunsheetBuilder.targets b/eng/TestRunsheetBuilder/TestRunsheetBuilder.targets deleted file mode 100644 index 7ca8ca2b8c1..00000000000 --- a/eng/TestRunsheetBuilder/TestRunsheetBuilder.targets +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - <_RequiresPackages Condition=" '$(MSBuildProjectName)' == 'Aspire.EndToEnd.Tests' ">true - - <_CreateRunsheet>false - <_CreateRunsheet Condition=" '$(_RequiresPackages)' == 'true' and '$(FullE2e)' == 'true' ">true - <_CreateRunsheet Condition=" '$(_RequiresPackages)' != 'true' and '$(FullE2e)' != 'true' ">true - - <_CreateRunsheet Condition=" '$(MSBuildProjectName)' == 'Aspire.Templates.Tests' ">false - - <_PreCommand>$(TestRunnerPreCommand) - - - - - <_TestRunsheet>$([System.String]::Copy('$(MSBuildProjectName)').Replace('Aspire.', '')) - <_TestRunsheetFileNameWindows>$(ArtifactsTmpDir)/$(_TestRunsheet).win.runsheet.json - <_TestRunsheetFileNameLinux>$(ArtifactsTmpDir)/$(_TestRunsheet).linux.runsheet.json - <_TestRunsheetFileNameMacOS>$(ArtifactsTmpDir)/$(_TestRunsheet).macos.runsheet.json - - <_TestBinLog>$([MSBuild]::NormalizePath($(ArtifactsLogDir), '$(_TestRunsheet).binlog')) - - <_RelativeTestProjectPath>$([System.String]::Copy('$(MSBuildProjectFullPath)').Replace('$(RepoRoot)', '%24(pwd)/')) - <_RelativeTestBinLog>$([System.String]::Copy('$(_TestBinLog)').Replace('$(RepoRoot)', '%24(pwd)/')) - - <_TestRunnerWindows>./eng/build.ps1 - <_TestRunnerLinux>./eng/build.sh - <_TestRunnerMacOS>./eng/build.sh - <_TestCommand>-restore -build -test -projects "$(_RelativeTestProjectPath)" /bl:"$(_RelativeTestBinLog)" -c $(Configuration) -ci - - - <_TestCommand Condition=" '$(_RequiresPackages)' == 'true' ">$(_TestCommand) /p:TestsRunningOutsideOfRepo=true - - - <_PreCommand>$([System.String]::Copy($(_PreCommand)).Replace("\", "/").Replace('"', '\"')) - <_TestCommand>$([System.String]::Copy($(_TestCommand)).Replace("\", "/").Replace('"', '\"')) - - <_TestRunsheetWindows>{ "label": "w: $(_TestRunsheet)", "project": "$(_TestRunsheet)", "os": "windows-latest", "command": "./eng/build.ps1 $(_TestCommand)" } - <_TestRunsheetLinux>{ "label": "l: $(_TestRunsheet)", "project": "$(_TestRunsheet)", "os": "ubuntu-latest", "command": "$(_PreCommand)./eng/build.sh $(_TestCommand)" } - <_TestRunsheetMacOS>{ "label": "m: $(_TestRunsheet)", "project": "$(_TestRunsheet)", "os": "macos-latest", "command": "$(_PreCommand)./eng/build.sh $(_TestCommand)" } - - - - <_OutputFiles Include="$(_TestRunsheetFileNameWindows)" /> - <_OutputFiles Include="$(_TestRunsheetFileNameLinux)" /> - <_OutputFiles Include="$(_TestRunsheetFileNameMacOS)" /> - - - - - - - - - - - - - - - - - - diff --git a/eng/Testing.props b/eng/Testing.props index d24ccae9410..4af4e6a93b0 100644 --- a/eng/Testing.props +++ b/eng/Testing.props @@ -2,6 +2,12 @@ true + + 20m + 10m + 15m + 10m + true true @@ -15,6 +21,9 @@ <_NonQuarantinedTestRunAdditionalArgs>--filter-not-trait "quarantined=true" <_OuterloopTestRunAdditionalArgs>--filter-trait "outerloop=true" <_NonOuterloopTestRunAdditionalArgs>--filter-not-trait "outerloop=true" + + <_ShortName Condition="'$(TestShortName)' != ''">$(TestShortName) + <_ShortName Condition="'$(_ShortName)' == ''">$([System.IO.Path]::GetFileNameWithoutExtension('$(MSBuildProjectName)').Replace('Aspire.', '').Replace('.Tests', '')) diff --git a/eng/Testing.targets b/eng/Testing.targets index 853a3949d3b..f9e66d2b601 100644 --- a/eng/Testing.targets +++ b/eng/Testing.targets @@ -43,6 +43,8 @@ If we haven't detected the tests are run on build agents, then we presume we're running tests locally. --> true + + false @@ -108,5 +110,4 @@ - diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index ab5a2b4977c..7abce19a784 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -237,6 +237,7 @@ steps: -configuration ${{ parameters.buildConfig }} -pack /p:PrepareForHelix=${{ lower(eq(parameters.runHelixTests, 'true')) }} + /p:ArchiveTests=${{ lower(eq(parameters.runHelixTests, 'true')) }} /bl:${{ parameters.repoLogPath }}/build.binlog $(_OfficialBuildIdArgs) displayName: Build diff --git a/eng/scripts/build-test-matrix.ps1 b/eng/scripts/build-test-matrix.ps1 new file mode 100644 index 00000000000..68f74d29f55 --- /dev/null +++ b/eng/scripts/build-test-matrix.ps1 @@ -0,0 +1,330 @@ +<# +.SYNOPSIS + Builds the canonical test matrix from test enumeration files. + +.DESCRIPTION + This script processes test metadata files and generates a canonical test matrix + that can be consumed by any CI platform (GitHub Actions, Azure DevOps, etc.). + + The script: + 1. Collects all .tests-metadata.json files from the artifacts directory + 2. Processes regular and split test projects + 3. Applies default values for missing properties + 4. Normalizes boolean values + 5. Outputs a canonical JSON format with supportedOSes arrays (not expanded) + + The output format is platform-agnostic. Each CI platform should have a thin + script to expand supportedOSes into platform-specific runner configurations. + +.PARAMETER ArtifactsDir + Path to the artifacts directory containing .tests-metadata.json files. + +.PARAMETER OutputMatrixFile + Path to write the canonical test matrix JSON file. + +.NOTES + PowerShell 7+ + + Output format: + { + "tests": [ { entry with supportedOSes array and requiresNugets boolean }, ... ] + } +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] + [string]$ArtifactsDir, + + [Parameter(Mandatory=$true)] + [string]$OutputMatrixFile +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Default values applied to all entries +$script:defaults = @{ + extraTestArgs = '' + requiresNugets = $false + requiresTestSdk = $false + testSessionTimeout = '20m' + testHangTimeout = '10m' + supportedOSes = @('windows', 'linux', 'macos') +} + +Write-Host "Building canonical test matrix" +Write-Host "Artifacts directory: $ArtifactsDir" + +# Helper function to normalize boolean values +function ConvertTo-Boolean { + param($Value) + return ($Value -eq 'true' -or $Value -eq $true) +} + +# Helper function to apply defaults and normalize an entry +function Complete-EntryWithDefaults { + param([Parameter(Mandatory=$true)]$Entry) + + # Apply defaults for missing properties + if (-not $Entry['testSessionTimeout']) { $Entry['testSessionTimeout'] = $script:defaults.testSessionTimeout } + if (-not $Entry['testHangTimeout']) { $Entry['testHangTimeout'] = $script:defaults.testHangTimeout } + if (-not $Entry.Contains('extraTestArgs')) { $Entry['extraTestArgs'] = $script:defaults.extraTestArgs } + if (-not $Entry['supportedOSes'] -or $Entry['supportedOSes'].Count -eq 0) { + $Entry['supportedOSes'] = $script:defaults.supportedOSes + } + + # Normalize boolean values + $Entry['requiresNugets'] = if ($Entry.Contains('requiresNugets')) { ConvertTo-Boolean $Entry['requiresNugets'] } else { $false } + $Entry['requiresTestSdk'] = if ($Entry.Contains('requiresTestSdk')) { ConvertTo-Boolean $Entry['requiresTestSdk'] } else { $false } + $Entry['requiresCliArchive'] = if ($Entry.Contains('requiresCliArchive')) { ConvertTo-Boolean $Entry['requiresCliArchive'] } else { $false } + + return $Entry +} + +# Helper function to create matrix entry for regular (non-split) tests +function New-RegularTestEntry { + param( + [Parameter(Mandatory=$false)] + $Metadata = $null + ) + + $entry = [ordered]@{ + type = 'regular' + projectName = $Metadata.projectName + name = $Metadata.shortName + shortname = $Metadata.shortName + testProjectPath = $Metadata.testProjectPath + workitemprefix = $Metadata.projectName + splitTests = $false + } + + # Add metadata if available + if ($Metadata) { + if ($Metadata.PSObject.Properties['testSessionTimeout']) { $entry['testSessionTimeout'] = $Metadata.testSessionTimeout } + if ($Metadata.PSObject.Properties['testHangTimeout']) { $entry['testHangTimeout'] = $Metadata.testHangTimeout } + if ($Metadata.PSObject.Properties['requiresNugets']) { $entry['requiresNugets'] = $Metadata.requiresNugets } + if ($Metadata.PSObject.Properties['requiresTestSdk']) { $entry['requiresTestSdk'] = $Metadata.requiresTestSdk } + if ($Metadata.PSObject.Properties['requiresCliArchive']) { $entry['requiresCliArchive'] = $Metadata.requiresCliArchive } + if ($Metadata.PSObject.Properties['extraTestArgs'] -and $Metadata.extraTestArgs) { $entry['extraTestArgs'] = $Metadata.extraTestArgs } + } + + # Add supported OSes + $entry['supportedOSes'] = @($Metadata.supportedOSes) + + return Complete-EntryWithDefaults $entry +} + +# Helper function to create matrix entry for collection-based split tests +function New-CollectionTestEntry { + param( + [Parameter(Mandatory=$true)] + [string]$CollectionName, + [Parameter(Mandatory=$true)] + $Metadata, + [Parameter(Mandatory=$true)] + [bool]$IsUncollected + ) + + $suffix = if ($IsUncollected) { 'uncollected' } else { $CollectionName } + $baseShortName = if ($Metadata.shortName) { $Metadata.shortName } else { $Metadata.projectName } + + $entry = [ordered]@{ + type = 'collection' + projectName = $Metadata.projectName + name = if ($IsUncollected) { $baseShortName } else { "$baseShortName-$suffix" } + shortname = if ($IsUncollected) { $baseShortName } else { "$baseShortName-$suffix" } + testProjectPath = $Metadata.testProjectPath + workitemprefix = "$($Metadata.projectName)_$suffix" + collection = $CollectionName + splitTests = $true + } + + # Use uncollected timeouts if available, otherwise use regular + if ($IsUncollected) { + if ($Metadata.PSObject.Properties['uncollectedTestsSessionTimeout']) { + $entry['testSessionTimeout'] = $Metadata.uncollectedTestsSessionTimeout + } elseif ($Metadata.PSObject.Properties['testSessionTimeout']) { + $entry['testSessionTimeout'] = $Metadata.testSessionTimeout + } + + if ($Metadata.PSObject.Properties['uncollectedTestsHangTimeout']) { + $entry['testHangTimeout'] = $Metadata.uncollectedTestsHangTimeout + } elseif ($Metadata.PSObject.Properties['testHangTimeout']) { + $entry['testHangTimeout'] = $Metadata.testHangTimeout + } + } else { + if ($Metadata.PSObject.Properties['testSessionTimeout']) { $entry['testSessionTimeout'] = $Metadata.testSessionTimeout } + if ($Metadata.PSObject.Properties['testHangTimeout']) { $entry['testHangTimeout'] = $Metadata.testHangTimeout } + } + + if ($Metadata.PSObject.Properties['requiresNugets']) { $entry['requiresNugets'] = $Metadata.requiresNugets } + if ($Metadata.PSObject.Properties['requiresTestSdk']) { $entry['requiresTestSdk'] = $Metadata.requiresTestSdk } + if ($Metadata.PSObject.Properties['requiresCliArchive']) { $entry['requiresCliArchive'] = $Metadata.requiresCliArchive } + + # Add test filter for collection-based splitting + if ($IsUncollected) { + $entry['extraTestArgs'] = '--filter-not-trait "Partition=*"' + } else { + $entry['extraTestArgs'] = "--filter-trait `"Partition=$CollectionName`"" + } + + # Add supported OSes from metadata + if ($Metadata.PSObject.Properties['supportedOSes']) { + $entry['supportedOSes'] = @($Metadata.supportedOSes) + } + + return Complete-EntryWithDefaults $entry +} + +# Helper function to create matrix entry for class-based split tests +function New-ClassTestEntry { + param( + [Parameter(Mandatory=$true)] + [string]$ClassName, + [Parameter(Mandatory=$true)] + $Metadata + ) + + # Extract short class name (last segment after last dot) + $shortClassName = $ClassName.Split('.')[-1] + $baseShortName = if ($Metadata.shortName) { $Metadata.shortName } else { $Metadata.projectName } + + $entry = [ordered]@{ + type = 'class' + projectName = $Metadata.projectName + name = "$baseShortName-$shortClassName" + shortname = "$baseShortName-$shortClassName" + testProjectPath = $Metadata.testProjectPath + workitemprefix = "$($Metadata.projectName)_$shortClassName" + classname = $ClassName + splitTests = $true + } + + if ($Metadata.PSObject.Properties['testSessionTimeout']) { $entry['testSessionTimeout'] = $Metadata.testSessionTimeout } + if ($Metadata.PSObject.Properties['testHangTimeout']) { $entry['testHangTimeout'] = $Metadata.testHangTimeout } + if ($Metadata.PSObject.Properties['requiresNugets']) { $entry['requiresNugets'] = $Metadata.requiresNugets } + if ($Metadata.PSObject.Properties['requiresTestSdk']) { $entry['requiresTestSdk'] = $Metadata.requiresTestSdk } + if ($Metadata.PSObject.Properties['requiresCliArchive']) { $entry['requiresCliArchive'] = $Metadata.requiresCliArchive } + + # Add test filter for class-based splitting + $entry['extraTestArgs'] = "--filter-class `"$ClassName`"" + + # Add supported OSes from metadata + if ($Metadata.PSObject.Properties['supportedOSes']) { + $entry['supportedOSes'] = @($Metadata.supportedOSes) + } + + return Complete-EntryWithDefaults $entry +} + +# 1. Collect all metadata files +$metadataFiles = @(Get-ChildItem -Path $ArtifactsDir -Filter "*.tests-metadata.json" -Recurse -ErrorAction SilentlyContinue) + +if ($metadataFiles.Count -eq 0) { + Write-Warning "No test metadata files found in $ArtifactsDir" + # Create empty canonical matrix + $canonicalMatrix = @{ + tests = @() + } + $canonicalMatrix | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputMatrixFile -Encoding UTF8 + Write-Host "Created empty test matrix: $OutputMatrixFile" + exit 0 +} + +Write-Host "Found $($metadataFiles.Count) test metadata file(s)" + +# 2. Build matrix entries +$matrixEntries = [System.Collections.Generic.List[object]]::new() + +foreach ($metadataFile in $metadataFiles) { + $metadataObject = Get-Content -Raw -Path $metadataFile.FullName | ConvertFrom-Json + + Write-Host "Processing: $($metadataObject.projectName)" + + # Check if this is a split test with metadata + if ($metadataObject.splitTests -eq 'true') { + Write-Host " → Split test (processing partitions/classes)" + + $metaFile = $metadataFile.FullName + $partitionsFile = $metaFile -replace '\.tests-metadata\.json$', '.tests-partitions.json' + + if (-not (Test-Path $partitionsFile)) { + throw "Test partitions file not found: $partitionsFile" + } + + $metadata = Get-Content -Raw -Path $metaFile | ConvertFrom-Json + + # Add supported OSes to metadata from enumeration + $metadata | Add-Member -Force -MemberType NoteProperty -Name 'supportedOSes' -Value $metadataObject.supportedOSes + + # Extract the array testPartitions + $partitionsJson = Get-Content -Raw -Path $partitionsFile | ConvertFrom-Json + $testPartitions = $partitionsJson.testPartitions + + $partitionCount = 0 + $classCount = 0 + + foreach ($testPartition in $testPartitions) { + $testPartition = $testPartition.Trim() + if ([string]::IsNullOrWhiteSpace($testPartition)) { continue } + + if ($testPartition -match '^collection:(.+)$') { + # Collection/partition entry + $collectionName = $Matches[1] + $entry = New-CollectionTestEntry -CollectionName $collectionName -Metadata $metadata -IsUncollected $false + $matrixEntries.Add($entry) + $partitionCount++ + } + elseif ($testPartition -match '^uncollected:\*$') { + # Uncollected tests entry + $entry = New-CollectionTestEntry -CollectionName '*' -Metadata $metadata -IsUncollected $true + $matrixEntries.Add($entry) + $partitionCount++ + } + elseif ($testPartition -match '^class:(.+)$') { + # Class-based entry + $className = $Matches[1] + $entry = New-ClassTestEntry -ClassName $className -Metadata $metadata + $matrixEntries.Add($entry) + $classCount++ + } + } + + Write-Host " ✓ Added $partitionCount partition(s) and $classCount class(es)" + } + else { + # Regular (non-split) test + $entry = New-RegularTestEntry -Metadata $metadataObject + $matrixEntries.Add($entry) + } +} + +# 3. Sort entries and output +Write-Host "" +Write-Host "Generated $($matrixEntries.Count) total matrix entries" + +$sortedEntries = @($matrixEntries | Sort-Object -Property projectName, name) + +$requiresNugetsCount = @($sortedEntries | Where-Object { $_.requiresNugets -eq $true }).Count +$noNugetsCount = @($sortedEntries | Where-Object { $_.requiresNugets -ne $true }).Count + +Write-Host " - Requiring NuGets: $requiresNugetsCount" +Write-Host " - Not requiring NuGets: $noNugetsCount" + +# 4. Write canonical matrix +$canonicalMatrix = [ordered]@{ + tests = $sortedEntries +} + +$outputDir = [System.IO.Path]::GetDirectoryName($OutputMatrixFile) +if ($outputDir -and -not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null +} + +$canonicalMatrix | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputMatrixFile -Encoding UTF8 + +Write-Host "" +Write-Host "✓ Canonical matrix written to: $OutputMatrixFile" +Write-Host "" +Write-Host "Matrix build complete!" diff --git a/eng/scripts/expand-test-matrix-github.ps1 b/eng/scripts/expand-test-matrix-github.ps1 new file mode 100644 index 00000000000..936351d613b --- /dev/null +++ b/eng/scripts/expand-test-matrix-github.ps1 @@ -0,0 +1,140 @@ +<# +.SYNOPSIS + Expands the canonical test matrix for GitHub Actions. + +.DESCRIPTION + This script takes the canonical test matrix (output by build-test-matrix.ps1) + and transforms it for GitHub Actions consumption by: + 1. Expanding each entry for every OS in its supportedOSes array + 2. Mapping OS names to GitHub runner names (linux -> ubuntu-latest, etc.) + 3. Outputting a single all_tests matrix with all entries + + This is the platform-specific layer for GitHub Actions. Azure DevOps would + have a similar script with different runner mappings and output format. + + Downstream consumers (e.g., tests.yml) are responsible for splitting the + matrix by dependency type and handling overflow. + +.PARAMETER CanonicalMatrixFile + Path to the canonical test matrix JSON file (output of build-test-matrix.ps1). + +.PARAMETER OutputMatrixFile + Output file path for the matrix JSON. When set, writes the all_tests matrix. + +.PARAMETER OutputToGitHubEnv + If set, outputs to GITHUB_OUTPUT environment file instead of files. + +.NOTES + PowerShell 7+ +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] + [string]$CanonicalMatrixFile, + + [Parameter(Mandatory=$false)] + [string]$OutputMatrixFile = "", + + [Parameter(Mandatory=$false)] + [switch]$OutputToGitHubEnv +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# GitHub runner mappings +$runnerMap = @{ + 'windows' = 'windows-latest' + 'linux' = 'ubuntu-latest' + 'macos' = 'macos-latest' +} + +# Valid OS values +$validOSes = @('windows', 'linux', 'macos') + +function Expand-MatrixEntriesByOS { + param( + [Parameter(Mandatory=$true)] + [array]$Entries + ) + + $expandedEntries = @() + + foreach ($entry in $Entries) { + # Get supported OSes (default to all if not specified) + $hasSupportedOSes = [bool]($entry.PSObject.Properties.Name -contains 'supportedOSes') + $supportedOSes = if ($hasSupportedOSes -and $entry.supportedOSes -and $entry.supportedOSes.Count -gt 0) { + $entry.supportedOSes + } else { + $validOSes + } + + # Validate and expand for each OS + foreach ($os in $supportedOSes) { + $osLower = $os.ToLowerInvariant() + + if ($osLower -notin $validOSes) { + Write-Warning "Invalid OS '$os' in supportedOSes for test '$($entry.name)'. Skipping." + continue + } + + # Create a copy of the entry for this OS + $expandedEntry = [ordered]@{} + foreach ($prop in $entry.PSObject.Properties) { + if ($prop.Name -ne 'supportedOSes') { + $expandedEntry[$prop.Name] = $prop.Value + } + } + + # Add GitHub-specific runner + $expandedEntry['runs-on'] = $runnerMap[$osLower] + + $expandedEntries += [PSCustomObject]$expandedEntry + } + } + + return $expandedEntries +} + +# Read canonical matrix +if (-not (Test-Path $CanonicalMatrixFile)) { + Write-Error "Canonical matrix file not found: $CanonicalMatrixFile" + exit 1 +} + +Write-Host "Reading canonical matrix from: $CanonicalMatrixFile" +$canonicalMatrix = Get-Content -Raw $CanonicalMatrixFile | ConvertFrom-Json + +# Expand matrix entries by OS +$allEntries = @() + +if ($canonicalMatrix.PSObject.Properties.Name -contains 'tests' -and $canonicalMatrix.tests) { + $allEntries = Expand-MatrixEntriesByOS -Entries $canonicalMatrix.tests +} + +Write-Host "Expanded matrix: $($allEntries.Count) total entries" + +$allTestsMatrix = ConvertTo-Json @{ include = @($allEntries) } -Compress -Depth 10 + +# Output results +if ($OutputToGitHubEnv) { + # Output to GitHub Actions environment + if ($env:GITHUB_OUTPUT) { + "all_tests=$allTestsMatrix" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "✓ Matrix written to GITHUB_OUTPUT ($($allEntries.Count) entries)" + } else { + Write-Error "GITHUB_OUTPUT environment variable not set" + exit 1 + } +} elseif ($OutputMatrixFile) { + $allTestsMatrix | Set-Content -Path $OutputMatrixFile -Encoding UTF8 + Write-Host "✓ Matrix written to: $OutputMatrixFile" +} else { + # Output to console for debugging + Write-Host "" + Write-Host "All tests: $allTestsMatrix" +} + +Write-Host "" +Write-Host "GitHub Actions matrix expansion complete!" diff --git a/eng/scripts/split-test-matrix-by-deps.ps1 b/eng/scripts/split-test-matrix-by-deps.ps1 new file mode 100644 index 00000000000..6d04cc57729 --- /dev/null +++ b/eng/scripts/split-test-matrix-by-deps.ps1 @@ -0,0 +1,142 @@ +<# +.SYNOPSIS + Splits an all_tests matrix by dependency type for GitHub Actions. + +.DESCRIPTION + Takes a flat all_tests matrix JSON (already OS-expanded) and splits it into + four dependency-based matrices for GitHub Actions consumption: + 1. tests_matrix_no_nugets (primary) — tests with no package dependencies + 2. tests_matrix_no_nugets_overflow — overflow when primary exceeds threshold + 3. tests_matrix_requires_nugets — tests needing built NuGet packages + 4. tests_matrix_requires_cli_archive — tests needing CLI native archives + + The overflow mechanism keeps each matrix under GitHub Actions' 256-job limit. + +.PARAMETER AllTestsMatrix + JSON string of the all_tests matrix ({"include": [...]}). + +.PARAMETER AllTestsMatrixFile + Path to a JSON file containing the all_tests matrix. + Mutually exclusive with AllTestsMatrix. + +.PARAMETER OutputToGitHubEnv + If set, outputs to GITHUB_OUTPUT environment file. + +.PARAMETER OverflowThreshold + Maximum entries in the no_nugets primary bucket before overflow kicks in. + Defaults to 250 (GitHub Actions hard limit is 256). + +.NOTES + PowerShell 7+ +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory=$false)] + [string]$AllTestsMatrix = "", + + [Parameter(Mandatory=$false)] + [string]$AllTestsMatrixFile = "", + + [Parameter(Mandatory=$false)] + [switch]$OutputToGitHubEnv, + + [Parameter(Mandatory=$false)] + [int]$OverflowThreshold = 250 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$maxMatrixSize = 256 + +# Read input +if ($AllTestsMatrixFile) { + if (-not (Test-Path $AllTestsMatrixFile)) { + Write-Error "Matrix file not found: $AllTestsMatrixFile" + exit 1 + } + $AllTestsMatrix = Get-Content -Raw $AllTestsMatrixFile +} + +if (-not $AllTestsMatrix) { + Write-Error "Either -AllTestsMatrix or -AllTestsMatrixFile must be provided." + exit 1 +} + +$matrix = $AllTestsMatrix | ConvertFrom-Json +$allEntries = @() +if ($matrix.include -and $matrix.include.Count -gt 0) { + $allEntries = @($matrix.include) +} + +Write-Host "Input matrix: $($allEntries.Count) total entries" + +# Split into categories based on dependency requirements +$cliArchiveEntries = @($allEntries | Where-Object { $_.PSObject.Properties.Name -contains 'requiresCliArchive' -and $_.requiresCliArchive -eq $true }) +$nugetEntries = @($allEntries | Where-Object { + ($_.PSObject.Properties.Name -contains 'requiresNugets' -and $_.requiresNugets -eq $true) -and + -not ($_.PSObject.Properties.Name -contains 'requiresCliArchive' -and $_.requiresCliArchive -eq $true) +}) +$noNugetEntries = @($allEntries | Where-Object { + -not ($_.PSObject.Properties.Name -contains 'requiresNugets' -and $_.requiresNugets -eq $true) -and + -not ($_.PSObject.Properties.Name -contains 'requiresCliArchive' -and $_.requiresCliArchive -eq $true) +}) + +Write-Host " - No nugets: $($noNugetEntries.Count)" +Write-Host " - Requires nugets: $($nugetEntries.Count)" +Write-Host " - Requires CLI archive: $($cliArchiveEntries.Count)" + +# Split no_nugets into primary + overflow +$noNugetPrimary = @() +$noNugetOverflow = @() + +if ($noNugetEntries.Count -le $OverflowThreshold) { + $noNugetPrimary = $noNugetEntries +} else { + $noNugetPrimary = @($noNugetEntries[0..($OverflowThreshold - 1)]) + $noNugetOverflow = @($noNugetEntries[$OverflowThreshold..($noNugetEntries.Count - 1)]) + Write-Host " ↳ no_nugets overflow: $($noNugetPrimary.Count) primary + $($noNugetOverflow.Count) overflow" +} + +# Validate no bucket exceeds the hard limit +$buckets = @{ + 'tests_matrix_no_nugets' = $noNugetPrimary + 'tests_matrix_no_nugets_overflow' = $noNugetOverflow + 'tests_matrix_requires_nugets' = $nugetEntries + 'tests_matrix_requires_cli_archive' = $cliArchiveEntries +} + +foreach ($name in $buckets.Keys) { + if ($buckets[$name].Count -gt $maxMatrixSize) { + Write-Error "$name has $($buckets[$name].Count) entries, exceeding the GitHub Actions limit of $maxMatrixSize." + exit 1 + } +} + +# Convert to JSON +$results = @{} +foreach ($name in $buckets.Keys) { + $results[$name] = ConvertTo-Json @{ include = @($buckets[$name]) } -Compress -Depth 10 +} + +# Output +if ($OutputToGitHubEnv) { + if ($env:GITHUB_OUTPUT) { + foreach ($name in $results.Keys) { + "$name=$($results[$name])" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + } + Write-Host "✓ Split matrices written to GITHUB_OUTPUT" + } else { + Write-Error "GITHUB_OUTPUT environment variable not set" + exit 1 + } +} else { + # Output to console for debugging + foreach ($name in $results.Keys) { + Write-Host "${name}: $($results[$name])" + } +} + +Write-Host "" +Write-Host "Matrix split complete!" diff --git a/eng/scripts/split-test-projects-for-ci.ps1 b/eng/scripts/split-test-projects-for-ci.ps1 new file mode 100644 index 00000000000..b7a87364c1f --- /dev/null +++ b/eng/scripts/split-test-projects-for-ci.ps1 @@ -0,0 +1,186 @@ +<# +.SYNOPSIS + Discovers test partitions or classes for CI test splitting. + +.DESCRIPTION + Determines how to split a test project for parallel CI execution: + + 1. Extracts partitions using ExtractTestPartitions tool: + - Scans assembly for [Trait("Partition", "name")] attributes + - If partitions found → partition mode + - Fails if the tool cannot be built or run + + 2. Uses class-based splitting if no partitions are found: + - Runs --list-tests to enumerate test classes + - Creates one entry per test class + + Outputs a .tests-partitions.json file with entries like: + Partition mode: ["collection:Name", ..., "uncollected:*"] + Class mode: ["class:Full.Namespace.ClassName", ...] + + The uncollected:* entry ensures tests without partition traits still run. + +.PARAMETER TestAssemblyPath + Path to the test assembly DLL for extracting partition attributes. + +.PARAMETER RunCommand + The command to run the test assembly (e.g., "dotnet exec "). + Only invoked if partition extraction fails and class-based splitting is needed. + +.PARAMETER TestClassNamePrefixForCI + Namespace prefix used to recognize test classes (e.g., Aspire.Hosting.Tests). + +.PARAMETER TestPartitionsJsonFile + Path to write the .tests-partitions.json output file. + +.PARAMETER RepoRoot + Path to the repository root (for locating the ExtractTestPartitions tool). + +.NOTES + PowerShell 7+ + Fails fast if ExtractTestPartitions cannot be built or run. + Fails fast if zero test classes discovered when in class mode. + Only runs --list-tests when no partitions are found in the assembly. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] + [string]$TestAssemblyPath, + + [Parameter(Mandatory=$true)] + [string]$RunCommand, + + [Parameter(Mandatory=$true)] + [string]$TestClassNamePrefixForCI, + + [Parameter(Mandatory=$true)] + [string]$TestPartitionsJsonFile, + + [Parameter(Mandatory=$true)] + [string]$RepoRoot +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not (Test-Path $TestAssemblyPath)) { + Write-Error "TestAssemblyPath not found: $TestAssemblyPath" +} + +$collections = [System.Collections.Generic.HashSet[string]]::new() +$classes = [System.Collections.Generic.HashSet[string]]::new() + +# Extract partitions using the ExtractTestPartitions tool +$partitionsFile = Join-Path ([System.IO.Path]::GetTempPath()) "partitions-$([System.Guid]::NewGuid()).txt" +try { + $toolPath = Join-Path $RepoRoot "artifacts/bin/ExtractTestPartitions/Release/net8.0/ExtractTestPartitions.dll" + + # Build the tool if it doesn't exist + if (-not (Test-Path $toolPath)) { + Write-Host "Building ExtractTestPartitions tool..." + $toolProjectPath = Join-Path $RepoRoot "tools/ExtractTestPartitions/ExtractTestPartitions.csproj" + & dotnet build $toolProjectPath -c Release --nologo -v quiet + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to build ExtractTestPartitions tool." + } + } + + if (-not (Test-Path $toolPath)) { + Write-Error "ExtractTestPartitions tool not found at $toolPath after build." + } + + Write-Host "Extracting partitions from assembly: $TestAssemblyPath" + $toolOutput = & dotnet $toolPath --assembly-path $TestAssemblyPath --output-file $partitionsFile 2>&1 + $toolExitCode = $LASTEXITCODE + + # Display tool output (informational) + if ($toolOutput) { + $toolOutput | Write-Host + } + + if ($toolExitCode -ne 0) { + Write-Error "ExtractTestPartitions failed with exit code $toolExitCode." + } + + # Read partitions if the file was created + if (Test-Path $partitionsFile) { + $partitionLines = Get-Content $partitionsFile -ErrorAction SilentlyContinue + if ($partitionLines) { + foreach ($partition in $partitionLines) { + if (-not [string]::IsNullOrWhiteSpace($partition)) { + $collections.Add($partition.Trim()) | Out-Null + } + } + Write-Host "Found $($collections.Count) partition(s) via attribute extraction" + } + } +} +finally { + # Clean up temp file + if (Test-Path $partitionsFile) { + Remove-Item $partitionsFile -ErrorAction SilentlyContinue + } +} + +# Determine mode: if we have partitions, use collection mode; otherwise fall back to class mode +$mode = if ($collections.Count -gt 0) { 'collection' } else { 'class' } + +# Only run --list-tests if we need class-based splitting (no partitions found) +if ($mode -eq 'class') { + Write-Host "No partitions found. Running --list-tests to extract class names..." + + # Run the test assembly with --list-tests to get all test names + $testOutput = & $RunCommand --filter-not-trait category=failing --list-tests 2>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Error "Test listing command failed with exit code $LASTEXITCODE" + } + + # Extract class names from test listing + # Match everything up to the last segment (method name), capturing the full class name + $classNamePattern = '^\s*(' + [Regex]::Escape($TestClassNamePrefixForCI) + '\..+)\.[^\.]+$' + + foreach ($line in $testOutput) { + $lineStr = $line.ToString().Trim() + # Extract class name from test name + # Format: "Namespace.SubNs.ClassName.MethodName(...)" or "Namespace.ClassName.MethodName" + # Strip any trailing parenthesized arguments: "Method(arg1, arg2)" → "Method" + $cleanLine = $lineStr -replace '\(.*\)$', '' + if ($cleanLine -match $classNamePattern) { + $className = $Matches[1] + $classes.Add($className) | Out-Null + } + } + + if ($classes.Count -eq 0) { + Write-Error "No test classes discovered matching prefix '$TestClassNamePrefixForCI'." + } +} + +$lines = [System.Collections.Generic.List[string]]::new() + +if ($mode -eq 'collection') { + foreach ($c in ($collections | Sort-Object)) { + $lines.Add("collection:$c") + } + $lines.Add("uncollected:*") +} else { + foreach ($cls in ($classes | Sort-Object)) { + $lines.Add("class:$cls") + } +} + +# Create tests partitions json file +try { + $testPartitionsJson = @{} + $testPartitionsJson | Add-Member -Force -MemberType NoteProperty -Name 'testPartitions' -Value @($lines) + $testPartitionsJson | ConvertTo-Json -Depth 20 | Set-Content -Path $TestPartitionsJsonFile -Encoding UTF8 +} catch { + Write-Warning "Failed updating metadata JSON: $_" +} + +Write-Host "Mode: $mode" +Write-Host "Collections discovered: $($collections.Count)" +Write-Host "Classes discovered: $($classes.Count)" +Write-Host "Test partitions JSON: $TestPartitionsJsonFile" diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj index 5bd4cf8e073..7567e9a0d4b 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj +++ b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj @@ -9,6 +9,10 @@ true Exe + true + true + true + false @@ -17,17 +21,15 @@ false true - + false false - + false false - - true - Aspire.Cli.EndToEnd.Tests + Aspire.Cli.EndToEnd.Tests true diff --git a/tests/Aspire.Cli.EndToEnd.Tests/README.md b/tests/Aspire.Cli.EndToEnd.Tests/README.md index c598511ac71..d34dbbffcb4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/README.md +++ b/tests/Aspire.Cli.EndToEnd.Tests/README.md @@ -12,14 +12,14 @@ These tests simulate real user interactions with the Aspire CLI by automating te - **`CliEndToEndTestBase`**: Base class providing Hex1b terminal setup, work directory management, and helper methods - **`HeadlessPresentationAdapter`**: A headless rendering adapter for running tests without a display -- **`CliEndToEndTestRunsheetBuilder`**: MSBuild targets that extract test classes and generate per-class runsheets +- **`TestEnumerationRunsheetBuilder`**: Unified MSBuild targets that extract test classes and generate per-class runsheets (via `SplitTestsOnCI=true`) ### CI Pipeline The CI infrastructure uses a matrix strategy to fan out test execution: -1. **Discovery Phase**: The `CliEndToEndTestRunsheetBuilder` builds the test project and runs `--list-tests` to discover all test classes -2. **Runsheet Generation**: A JSON runsheet entry is created for each unique test class +1. **Discovery Phase**: The `TestEnumerationRunsheetBuilder` builds the test project and invokes the `GenerateTestPartitionsForCI` target to discover all test classes +2. **Matrix Generation**: A matrix entry is created for each unique test class, expanded for the target platform 3. **Parallel Execution**: GitHub Actions creates a separate job for each test class, running them in parallel on different agents ```plaintext @@ -131,4 +131,4 @@ When adding a new test class: 2. Follow the test class structure above 3. The CI will automatically discover and run your tests as a separate job -No changes to the CI configuration are required - the runsheet builder automatically discovers all test classes. +No changes to the CI configuration are required — the `TestEnumerationRunsheetBuilder` automatically discovers all test classes in projects with `SplitTestsOnCI=true`. diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj index 9ce58253a7d..5094e1b3316 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj @@ -12,10 +12,10 @@ false - + false false - false + true false @@ -25,9 +25,12 @@ false false - - true - Aspire.Deployment.EndToEnd.Tests + + true + Aspire.Deployment.EndToEnd.Tests + + + EndToEnd true diff --git a/tests/Aspire.EndToEnd.Tests/Aspire.EndToEnd.Tests.csproj b/tests/Aspire.EndToEnd.Tests/Aspire.EndToEnd.Tests.csproj index ae826d9bb89..e9eaee04136 100644 --- a/tests/Aspire.EndToEnd.Tests/Aspire.EndToEnd.Tests.csproj +++ b/tests/Aspire.EndToEnd.Tests/Aspire.EndToEnd.Tests.csproj @@ -17,6 +17,8 @@ false testassets\testproject\ + true + <_BuildForTestsRunningOutsideOfRepo Condition="'$(TestsRunningOutsideOfRepo)' == 'true' or '$(ContinuousIntegrationBuild)' == 'true'">true $(_BuildForTestsRunningOutsideOfRepo) BUILD_FOR_TESTS_RUNNING_OUTSIDE_OF_REPO;$(DefineConstants) diff --git a/tests/Aspire.Hosting.Tests/AddConnectionStringTests.cs b/tests/Aspire.Hosting.Tests/AddConnectionStringTests.cs index db70e04beba..8f4ab20b768 100644 --- a/tests/Aspire.Hosting.Tests/AddConnectionStringTests.cs +++ b/tests/Aspire.Hosting.Tests/AddConnectionStringTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class AddConnectionStringTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/AddParameterTests.cs b/tests/Aspire.Hosting.Tests/AddParameterTests.cs index 8aab668c3ff..6d7a996d19c 100644 --- a/tests/Aspire.Hosting.Tests/AddParameterTests.cs +++ b/tests/Aspire.Hosting.Tests/AddParameterTests.cs @@ -10,6 +10,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class AddParameterTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/AppHostSmokeTests.cs b/tests/Aspire.Hosting.Tests/AppHostSmokeTests.cs index 8e2b99faf6f..a6c5b83ef38 100644 --- a/tests/Aspire.Hosting.Tests/AppHostSmokeTests.cs +++ b/tests/Aspire.Hosting.Tests/AppHostSmokeTests.cs @@ -13,6 +13,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class AppHostSmokeTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuildAnnotationTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuildAnnotationTests.cs index 425a99f723e..3cc448c125f 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuildAnnotationTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuildAnnotationTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class DockerfileBuildAnnotationTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderCallbackAnnotationTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderCallbackAnnotationTests.cs index 693e7a6dfdb..59b4db1e928 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderCallbackAnnotationTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderCallbackAnnotationTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class DockerfileBuilderCallbackAnnotationTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderCallbackContextTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderCallbackContextTests.cs index a677642dae4..0429b0008ef 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderCallbackContextTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderCallbackContextTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class DockerfileBuilderCallbackContextTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderEdgeCasesTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderEdgeCasesTests.cs index a3405903289..9d8d0caebc7 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderEdgeCasesTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderEdgeCasesTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class DockerfileBuilderEdgeCasesTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderIntegrationTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderIntegrationTests.cs index bebf4e2eaff..44e8c80ca35 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderIntegrationTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderIntegrationTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class DockerfileBuilderIntegrationTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderTests.cs index d0d5d28d7e3..25f86c9dee3 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileBuilderTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class DockerfileBuilderTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileStageTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileStageTests.cs index 383d8edcf65..741d85dd03b 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileStageTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileStageTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class DockerfileStageTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileStatementsTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileStatementsTests.cs index 584ec22632f..59867066e56 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileStatementsTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/DockerfileStatementsTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class DockerfileStatementsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/WithDockerfileBuilderTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/WithDockerfileBuilderTests.cs index a20193555b0..f8979bdd058 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/WithDockerfileBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/Docker/WithDockerfileBuilderTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel.Docker; +[Trait("Partition", "4")] public class WithDockerfileBuilderTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs index 51ef3f7d844..44f16084281 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel; +[Trait("Partition", "4")] public class EndpointHostHelpersTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/GenerateParameterDefaultTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/GenerateParameterDefaultTests.cs index e4bfae27a6a..32830b5f0c7 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/GenerateParameterDefaultTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/GenerateParameterDefaultTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel; +[Trait("Partition", "4")] public class GenerateParameterDefaultTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/McpServerEndpointAnnotationTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/McpServerEndpointAnnotationTests.cs index acdfe44d788..86be4f9d7da 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/McpServerEndpointAnnotationTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/McpServerEndpointAnnotationTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests.ApplicationModel; +[Trait("Partition", "4")] public class McpServerEndpointAnnotationTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/AsHttp2ServiceTests.cs b/tests/Aspire.Hosting.Tests/AsHttp2ServiceTests.cs index 49eaf1ec6a8..13a290833c8 100644 --- a/tests/Aspire.Hosting.Tests/AsHttp2ServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/AsHttp2ServiceTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class AsHttp2ServiceTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj index 7d0d8aa8df5..3c1e5c44925 100644 --- a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj +++ b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj @@ -10,6 +10,10 @@ false false + + + true + Aspire.Hosting.Tests diff --git a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs index aebdfc2fdc7..beb1ab00730 100644 --- a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class AspireStoreTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Ats/AtsCapabilityScannerTests.cs b/tests/Aspire.Hosting.Tests/Ats/AtsCapabilityScannerTests.cs index 129de89e883..fee9bad754a 100644 --- a/tests/Aspire.Hosting.Tests/Ats/AtsCapabilityScannerTests.cs +++ b/tests/Aspire.Hosting.Tests/Ats/AtsCapabilityScannerTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests.Ats; +[Trait("Partition", "4")] public class AtsCapabilityScannerTests { #region MapToAtsTypeId Tests diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AppHostBackchannelTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AppHostBackchannelTests.cs index 2814df6aa3e..f4ad00cd8a9 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AppHostBackchannelTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AppHostBackchannelTests.cs @@ -11,6 +11,7 @@ namespace Aspire.Hosting.Backchannel; +[Trait("Partition", "4")] public class AppHostBackchannelTests(ITestOutputHelper outputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs index ca09745ed9a..14a7c9e11d1 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Backchannel; +[Trait("Partition", "4")] public class AuxiliaryBackchannelRpcTargetTests(ITestOutputHelper outputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs index 1c06ea8f482..04c6fa4591a 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs @@ -14,6 +14,7 @@ namespace Aspire.Hosting.Backchannel; +[Trait("Partition", "4")] public class AuxiliaryBackchannelTests(ITestOutputHelper outputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs index 4c33cd5861b..9ce409a6212 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Backchannel; /// /// Validates that backchannel request/response types follow the contract rules. /// +[Trait("Partition", "4")] public class BackchannelContractTests { // V2 request/response types that must follow the contract diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ContainerResourceExecTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ContainerResourceExecTests.cs index 5801e16f2ca..8d6a93b5a8a 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ContainerResourceExecTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ContainerResourceExecTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests.Backchannel.Exec; +[Trait("Partition", "4")] public class ContainerResourceExecTests : ExecTestsBase { public ContainerResourceExecTests(ITestOutputHelper outputHelper) diff --git a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ProjectResourceExecTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ProjectResourceExecTests.cs index ad6749b3e14..99e2396a8fc 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/Exec/ProjectResourceExecTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/Exec/ProjectResourceExecTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests.Backchannel.Exec; +[Trait("Partition", "4")] public class ProjectResourceExecTests : ExecTestsBase { public ProjectResourceExecTests(ITestOutputHelper outputHelper) diff --git a/tests/Aspire.Hosting.Tests/Backchannel/JsonElementConversionTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/JsonElementConversionTests.cs index 735aef214c1..60f4300d368 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/JsonElementConversionTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/JsonElementConversionTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Backchannel; /// /// Tests for JsonElement to object conversion used in MCP tool calls. /// +[Trait("Partition", "4")] public class JsonElementConversionTests { // Access the private ConvertJsonElementToObject method via reflection for testing diff --git a/tests/Aspire.Hosting.Tests/Cli/CliOrphanDetectorTests.cs b/tests/Aspire.Hosting.Tests/Cli/CliOrphanDetectorTests.cs index 4827a0bef97..d0b03ac1973 100644 --- a/tests/Aspire.Hosting.Tests/Cli/CliOrphanDetectorTests.cs +++ b/tests/Aspire.Hosting.Tests/Cli/CliOrphanDetectorTests.cs @@ -14,6 +14,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "4")] public class CliOrphanDetectorTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs b/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs index d93fc762879..c0556bf3f02 100644 --- a/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs +++ b/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests.Codespaces; +[Trait("Partition", "4")] public class CodespacesUrlRewriterTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ConnectionPropertiesExtensionsTests.cs b/tests/Aspire.Hosting.Tests/ConnectionPropertiesExtensionsTests.cs index 0f83dab7597..7ead20d48cd 100644 --- a/tests/Aspire.Hosting.Tests/ConnectionPropertiesExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/ConnectionPropertiesExtensionsTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ConnectionPropertiesExtensionsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ContainerImagePushOptionsTests.cs b/tests/Aspire.Hosting.Tests/ContainerImagePushOptionsTests.cs index 7a69f0448c8..e0c60bce332 100644 --- a/tests/Aspire.Hosting.Tests/ContainerImagePushOptionsTests.cs +++ b/tests/Aspire.Hosting.Tests/ContainerImagePushOptionsTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ContainerImagePushOptionsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ContainerRegistryResourceTests.cs b/tests/Aspire.Hosting.Tests/ContainerRegistryResourceTests.cs index 6da28b7dbf9..97d5f9b574c 100644 --- a/tests/Aspire.Hosting.Tests/ContainerRegistryResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ContainerRegistryResourceTests.cs @@ -10,6 +10,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ContainerRegistryResourceTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs index b35b2459fb0..0579f09123f 100644 --- a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs +++ b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ContainerTunnelTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 9046e7b7585..14d479bba23 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -20,6 +20,7 @@ namespace Aspire.Hosting.Tests.Dashboard; +[Trait("Partition", "3")] public class DashboardLifecycleHookTests(ITestOutputHelper testOutputHelper) { [Theory] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardOptionsTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardOptionsTests.cs index 40b1c1b2c97..31de7debbb5 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardOptionsTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardOptionsTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.Dashboard; +[Trait("Partition", "3")] public class DashboardOptionsTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index ddf7ac4354e..cb346a339cb 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -14,6 +14,7 @@ namespace Aspire.Hosting.Tests.Dashboard; +[Trait("Partition", "3")] public class DashboardResourceTests(ITestOutputHelper testOutputHelper) { [Theory] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index dcb3c04b855..93c6c6c2c98 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -24,6 +24,7 @@ namespace Aspire.Hosting.Tests.Dashboard; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +[Trait("Partition", "3")] public class DashboardServiceTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs index a75ece18e6d..16c413bccb7 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests.Dashboard; +[Trait("Partition", "3")] public class ResourcePublisherTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/TransportOptionsValidatorTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/TransportOptionsValidatorTests.cs index 72311fbcdf3..2426e8d4ac7 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/TransportOptionsValidatorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/TransportOptionsValidatorTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests.Dashboard; +[Trait("Partition", "3")] public class TransportOptionsValidatorTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpCliArgsTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpCliArgsTests.cs index eb34f958236..e40b3e87f6a 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpCliArgsTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpCliArgsTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests.Dcp; +[Trait("Partition", "4")] public class DcpCliArgsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 8ccc1004794..6eb79c568c6 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -24,6 +24,7 @@ namespace Aspire.Hosting.Tests.Dcp; +[Trait("Partition", "4")] public class DcpExecutorTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs index e03ef806cd1..6915a440c42 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs @@ -15,6 +15,7 @@ namespace Aspire.Hosting.Tests.Dcp; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +[Trait("Partition", "4")] public sealed class DcpHostNotificationTests { private static Locations CreateTestLocations() diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpLogParserTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpLogParserTests.cs index e3ef62014f1..60dcdf3f352 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpLogParserTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpLogParserTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests.Dcp; +[Trait("Partition", "4")] public sealed class DcpLogParserTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/DcpVisibilityTests.cs b/tests/Aspire.Hosting.Tests/DcpVisibilityTests.cs index 06b3f57a93e..494a051b6a3 100644 --- a/tests/Aspire.Hosting.Tests/DcpVisibilityTests.cs +++ b/tests/Aspire.Hosting.Tests/DcpVisibilityTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class DcpVisibilityTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Devcontainers/SshRemoteOptionsTests.cs b/tests/Aspire.Hosting.Tests/Devcontainers/SshRemoteOptionsTests.cs index c72cfdb64ad..437a3f2ae99 100644 --- a/tests/Aspire.Hosting.Tests/Devcontainers/SshRemoteOptionsTests.cs +++ b/tests/Aspire.Hosting.Tests/Devcontainers/SshRemoteOptionsTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests.Devcontainers; +[Trait("Partition", "4")] public class SshRemoteOptionsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderExtensionsTests.cs index 86ee6520a4d..ea9738b549c 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderExtensionsTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class DistributedApplicationBuilderExtensionsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs index b8591546f46..10fe1b9f577 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs @@ -14,6 +14,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class DistributedApplicationBuilderTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationModelExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationModelExtensionsTests.cs index 5f15a048fe6..cd8e81047c6 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationModelExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationModelExtensionsTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class DistributedApplicationModelExtensionsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationOptionsTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationOptionsTests.cs index 141c063a80c..6f2ab75d550 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationOptionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationOptionsTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class DistributedApplicationOptionsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 7f0d216128b..32626064aa0 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -32,6 +32,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "1")] public class DistributedApplicationTests { private readonly ITestOutputHelper _testOutputHelper; diff --git a/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs b/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs index c5f8367d193..2a831de988a 100644 --- a/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class EndpointReferenceTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs b/tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs index 54b5807b4c0..de18d7f6b5b 100644 --- a/tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs +++ b/tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests.Eventing; +[Trait("Partition", "6")] public class DistributedApplicationBuilderEventingTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 174f0b8d365..e1fdcf3ae82 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ExecutableResourceBuilderExtensionTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceTests.cs index d21dc9598cc..a7536efb2ab 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ExecutableResourceTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs index bc7a802c394..de699dc664d 100644 --- a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs @@ -12,6 +12,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ExecutionConfigurationGathererTests { #region ArgumentsExecutionConfigurationGatherer Tests diff --git a/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs b/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs index af16db77edc..91d8094c2e0 100644 --- a/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs +++ b/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class ExpressionResolverTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/ExternalServiceTests.cs b/tests/Aspire.Hosting.Tests/ExternalServiceTests.cs index b3c8643fef6..c162b888c3a 100644 --- a/tests/Aspire.Hosting.Tests/ExternalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ExternalServiceTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ExternalServiceTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/FileSystemServiceTests.cs b/tests/Aspire.Hosting.Tests/FileSystemServiceTests.cs index f621cda9631..eb62d68b0e3 100644 --- a/tests/Aspire.Hosting.Tests/FileSystemServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/FileSystemServiceTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class FileSystemServiceTests { private static IConfiguration CreateConfiguration(bool preserveTempFiles = false) diff --git a/tests/Aspire.Hosting.Tests/Health/HealthStatusTests.cs b/tests/Aspire.Hosting.Tests/Health/HealthStatusTests.cs index 8192b30d2fb..7f19b8d22f1 100644 --- a/tests/Aspire.Hosting.Tests/Health/HealthStatusTests.cs +++ b/tests/Aspire.Hosting.Tests/Health/HealthStatusTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests.Health; +[Trait("Partition", "3")] public class HealthStatusTests { private const string StartingState = "Starting"; diff --git a/tests/Aspire.Hosting.Tests/Health/ResourceHealthCheckServiceTests.cs b/tests/Aspire.Hosting.Tests/Health/ResourceHealthCheckServiceTests.cs index 641b5e52b3f..b9f26fb8d39 100644 --- a/tests/Aspire.Hosting.Tests/Health/ResourceHealthCheckServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Health/ResourceHealthCheckServiceTests.cs @@ -13,6 +13,7 @@ namespace Aspire.Hosting.Tests.Health; +[Trait("Partition", "6")] public class ResourceHealthCheckServiceTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/HealthCheckTests.cs b/tests/Aspire.Hosting.Tests/HealthCheckTests.cs index c2b9c388624..ffb4b8ddc3e 100644 --- a/tests/Aspire.Hosting.Tests/HealthCheckTests.cs +++ b/tests/Aspire.Hosting.Tests/HealthCheckTests.cs @@ -11,6 +11,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class HealthCheckTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs index 87f1f70b5c3..a9cbb51add3 100644 --- a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs @@ -13,6 +13,7 @@ namespace Aspire.Hosting.Tests; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +[Trait("Partition", "2")] public class InteractionServiceTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/JsonFlattenerTests.cs b/tests/Aspire.Hosting.Tests/JsonFlattenerTests.cs index 1a454e83d72..e5c28a1e075 100644 --- a/tests/Aspire.Hosting.Tests/JsonFlattenerTests.cs +++ b/tests/Aspire.Hosting.Tests/JsonFlattenerTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class JsonFlattenerTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/KestrelConfigTests.cs b/tests/Aspire.Hosting.Tests/KestrelConfigTests.cs index 52b3e895e57..454724038f4 100644 --- a/tests/Aspire.Hosting.Tests/KestrelConfigTests.cs +++ b/tests/Aspire.Hosting.Tests/KestrelConfigTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class KestrelConfigTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/LaunchSettingsSerializerContextTests.cs b/tests/Aspire.Hosting.Tests/LaunchSettingsSerializerContextTests.cs index 6d23cc87065..51db140622c 100644 --- a/tests/Aspire.Hosting.Tests/LaunchSettingsSerializerContextTests.cs +++ b/tests/Aspire.Hosting.Tests/LaunchSettingsSerializerContextTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class LaunchSettingsSerializerContextTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/MSBuildTests.cs b/tests/Aspire.Hosting.Tests/MSBuildTests.cs index fbbd5a657a8..7100f876562 100644 --- a/tests/Aspire.Hosting.Tests/MSBuildTests.cs +++ b/tests/Aspire.Hosting.Tests/MSBuildTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "6")] public class MSBuildTests { /// diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index e429dea67a6..7ded2b36aba 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -15,6 +15,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class ManifestGenerationTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ModelNameTests.cs b/tests/Aspire.Hosting.Tests/ModelNameTests.cs index 8ba321b9ea1..fd295e3a364 100644 --- a/tests/Aspire.Hosting.Tests/ModelNameTests.cs +++ b/tests/Aspire.Hosting.Tests/ModelNameTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class ModelNameTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/OperationModesTests.cs b/tests/Aspire.Hosting.Tests/OperationModesTests.cs index 552a44aa751..09e2bdb5e50 100644 --- a/tests/Aspire.Hosting.Tests/OperationModesTests.cs +++ b/tests/Aspire.Hosting.Tests/OperationModesTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class OperationModesTests(ITestOutputHelper outputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index 44dbfb08f26..ccf353d8037 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -22,6 +22,7 @@ namespace Aspire.Hosting.Tests.Orchestrator; +[Trait("Partition", "3")] public class ApplicationOrchestratorTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs index 803a0d1d564..6e97b8335f4 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs @@ -19,6 +19,7 @@ namespace Aspire.Hosting.Tests.Orchestrator; +[Trait("Partition", "3")] public class ParameterProcessorTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/RelationshipEvaluatorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/RelationshipEvaluatorTests.cs index 0336483d2a1..40a5b54ecc2 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/RelationshipEvaluatorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/RelationshipEvaluatorTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests.Orchestrator; +[Trait("Partition", "3")] public class RelationshipEvaluatorTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs b/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs index 00382d148bb..c1f30c471a5 100644 --- a/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs +++ b/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Cli.Tests.Utils; +[Trait("Partition", "2")] public class PathLookupHelperTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index edfc9542a61..6b92e61482f 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -21,6 +21,7 @@ namespace Aspire.Hosting.Tests.Pipelines; +[Trait("Partition", "4")] public class DistributedApplicationPipelineTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Pipelines/PipelineLoggerProviderTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/PipelineLoggerProviderTests.cs index 413368f8915..a357e59321c 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/PipelineLoggerProviderTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/PipelineLoggerProviderTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests.Pipelines; +[Trait("Partition", "4")] public class PipelineLoggerProviderTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Pipelines/PipelineSummaryTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/PipelineSummaryTests.cs index 6488259f57d..86472f3eb1c 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/PipelineSummaryTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/PipelineSummaryTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests.Pipelines; +[Trait("Partition", "4")] public class PipelineSummaryTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/PortAllocatorTest.cs b/tests/Aspire.Hosting.Tests/PortAllocatorTest.cs index 4a0a639da97..91c5977b67e 100644 --- a/tests/Aspire.Hosting.Tests/PortAllocatorTest.cs +++ b/tests/Aspire.Hosting.Tests/PortAllocatorTest.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class PortAllocatorTest { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 607d8de079d..fd1e8eae9ca 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -25,6 +25,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ProjectResourceTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/PublishAsConnectionStringTests.cs b/tests/Aspire.Hosting.Tests/PublishAsConnectionStringTests.cs index 5994f42b260..68dc2fe1c99 100644 --- a/tests/Aspire.Hosting.Tests/PublishAsConnectionStringTests.cs +++ b/tests/Aspire.Hosting.Tests/PublishAsConnectionStringTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class PublishAsConnectionStringTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs b/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs index 5539cabd020..8d7cb8b788c 100644 --- a/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs +++ b/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class PublishAsDockerfileTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Publishing/DeploymentStateManagerTests.cs b/tests/Aspire.Hosting.Tests/Publishing/DeploymentStateManagerTests.cs index f54f71c985f..60941d6142f 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/DeploymentStateManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/DeploymentStateManagerTests.cs @@ -15,6 +15,7 @@ namespace Aspire.Hosting.Tests.Pipelines; +[Trait("Partition", "4")] public class DeploymentStateManagerTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs index f5b838e838e..8ff62053e6d 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs @@ -13,6 +13,7 @@ namespace Aspire.Hosting.Tests.Publishing; +[Trait("Partition", "4")] public class PublishingActivityReporterTests { private readonly InteractionService _interactionService = CreateInteractionService(); diff --git a/tests/Aspire.Hosting.Tests/Publishing/PublishingExtensionsTests.cs b/tests/Aspire.Hosting.Tests/Publishing/PublishingExtensionsTests.cs index bbee6389257..a284193d8ae 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/PublishingExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/PublishingExtensionsTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.Publishing; +[Trait("Partition", "4")] public class PublishingExtensionsTests { private readonly InteractionService _interactionService = PublishingActivityReporterTests.CreateInteractionService(); diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs index d4cb66a2e4c..ec735a9bae3 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs @@ -15,6 +15,7 @@ namespace Aspire.Hosting.Tests.Publishing; +[Trait("Partition", "4")] public class ResourceContainerImageBuilderTests(ITestOutputHelper output) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ReferenceExpressionTests.cs b/tests/Aspire.Hosting.Tests/ReferenceExpressionTests.cs index 8d221a06f31..07273e883cf 100644 --- a/tests/Aspire.Hosting.Tests/ReferenceExpressionTests.cs +++ b/tests/Aspire.Hosting.Tests/ReferenceExpressionTests.cs @@ -4,6 +4,7 @@ using System.Globalization; namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class ReferenceExpressionTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs b/tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs index 6f36a9caaf1..5a01357e3ec 100644 --- a/tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs +++ b/tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs @@ -10,6 +10,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class RequiredCommandAnnotationTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs index aa85fa15715..fd3b9d1c46a 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ResourceCommandAnnotationTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index 62e512cd89a..b87a2ab8fd6 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ResourceCommandServiceTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs index 0688a07e352..20df2382eb1 100644 --- a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ResourceDependencyTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs index 1f027781538..e30ae623550 100644 --- a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ResourceExtensionsTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs index 04e3acfb684..bb9e9310033 100644 --- a/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class ResourceLoggerServiceTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs index b01707040c0..aa04479746a 100644 --- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -10,6 +10,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class ResourceNotificationTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ResourceWithProbeTests.cs b/tests/Aspire.Hosting.Tests/ResourceWithProbeTests.cs index b1ce2c15d5c..f655cc8304f 100644 --- a/tests/Aspire.Hosting.Tests/ResourceWithProbeTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceWithProbeTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class ResourceWithProbeTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Schema/SchemaTests.cs b/tests/Aspire.Hosting.Tests/Schema/SchemaTests.cs index b8011481d84..ecc858eefb6 100644 --- a/tests/Aspire.Hosting.Tests/Schema/SchemaTests.cs +++ b/tests/Aspire.Hosting.Tests/Schema/SchemaTests.cs @@ -13,6 +13,7 @@ namespace Aspire.Hosting.Tests.Schema; +[Trait("Partition", "4")] public class SchemaTests { public static TheoryData> ApplicationSamples diff --git a/tests/Aspire.Hosting.Tests/SecretsStoreTests.cs b/tests/Aspire.Hosting.Tests/SecretsStoreTests.cs index b5ced6187e1..642ec293b5a 100644 --- a/tests/Aspire.Hosting.Tests/SecretsStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/SecretsStoreTests.cs @@ -12,6 +12,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class SecretsStoreTests { private static readonly ConstructorInfo s_userSecretsIdAttrCtor = typeof(UserSecretsIdAttribute).GetConstructor([typeof(string)])!; diff --git a/tests/Aspire.Hosting.Tests/SlimTestProgramTests.cs b/tests/Aspire.Hosting.Tests/SlimTestProgramTests.cs index ab3dd81ccc1..8fe4629d7b3 100644 --- a/tests/Aspire.Hosting.Tests/SlimTestProgramTests.cs +++ b/tests/Aspire.Hosting.Tests/SlimTestProgramTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; [Collection("SlimTestProgram")] +[Trait("Partition", "2")] public class SlimTestProgramTests { private readonly SlimTestProgramFixture _slimTestProgramFixture; diff --git a/tests/Aspire.Hosting.Tests/StableConnectionStringBuilderTests.cs b/tests/Aspire.Hosting.Tests/StableConnectionStringBuilderTests.cs index edce6a4d610..899d4d27808 100644 --- a/tests/Aspire.Hosting.Tests/StableConnectionStringBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/StableConnectionStringBuilderTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class StableConnectionStringBuilderTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/UserSecretsParameterDefaultTests.cs b/tests/Aspire.Hosting.Tests/UserSecretsParameterDefaultTests.cs index f9b883abd4b..1d983627048 100644 --- a/tests/Aspire.Hosting.Tests/UserSecretsParameterDefaultTests.cs +++ b/tests/Aspire.Hosting.Tests/UserSecretsParameterDefaultTests.cs @@ -14,6 +14,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class UserSecretsParameterDefaultTests { private static readonly ConstructorInfo s_userSecretsIdAttrCtor = typeof(UserSecretsIdAttribute).GetConstructor([typeof(string)])!; diff --git a/tests/Aspire.Hosting.Tests/Utils/CommandLineArgsParserTests.cs b/tests/Aspire.Hosting.Tests/Utils/CommandLineArgsParserTests.cs index f6258a2cdb4..913b1299ec3 100644 --- a/tests/Aspire.Hosting.Tests/Utils/CommandLineArgsParserTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/CommandLineArgsParserTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests.Utils; +[Trait("Partition", "4")] public class CommandLineArgsParserTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/Utils/ContainerReferenceParserTests.cs b/tests/Aspire.Hosting.Tests/Utils/ContainerReferenceParserTests.cs index ceb9846dc7a..3c0fda28f0e 100644 --- a/tests/Aspire.Hosting.Tests/Utils/ContainerReferenceParserTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/ContainerReferenceParserTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests.Utils; // Based on tests at https://github.com/distribution/reference/blob/main/reference_test.go +[Trait("Partition", "4")] public class ContainerReferenceParserTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Utils/EnvironmentVariableNameEncoderTests.cs b/tests/Aspire.Hosting.Tests/Utils/EnvironmentVariableNameEncoderTests.cs index 61750a0ad30..ca2b4cc946b 100644 --- a/tests/Aspire.Hosting.Tests/Utils/EnvironmentVariableNameEncoderTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/EnvironmentVariableNameEncoderTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests.Utils; +[Trait("Partition", "4")] public class EnvironmentVariableNameEncoderTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/Utils/PasswordGeneratorTests.cs b/tests/Aspire.Hosting.Tests/Utils/PasswordGeneratorTests.cs index 12951b6ebc1..3a83bb5c076 100644 --- a/tests/Aspire.Hosting.Tests/Utils/PasswordGeneratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/PasswordGeneratorTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests.Utils; +[Trait("Partition", "4")] public class PasswordGeneratorTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Utils/PeriodicRestartAsyncEnumerableTests.cs b/tests/Aspire.Hosting.Tests/Utils/PeriodicRestartAsyncEnumerableTests.cs index 66507b55ba7..2d8c0685c06 100644 --- a/tests/Aspire.Hosting.Tests/Utils/PeriodicRestartAsyncEnumerableTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/PeriodicRestartAsyncEnumerableTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests.Utils; +[Trait("Partition", "4")] public class PeriodicRestartAsyncEnumerableTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Utils/StringComparersTests.cs b/tests/Aspire.Hosting.Tests/Utils/StringComparersTests.cs index 186bbda119d..b7b97b46144 100644 --- a/tests/Aspire.Hosting.Tests/Utils/StringComparersTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/StringComparersTests.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Tests.Utils; +[Trait("Partition", "4")] public sealed class StringComparersTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Utils/UseCultureAttributeTests.cs b/tests/Aspire.Hosting.Tests/Utils/UseCultureAttributeTests.cs index 816dbb01f36..b9f35260f55 100644 --- a/tests/Aspire.Hosting.Tests/Utils/UseCultureAttributeTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/UseCultureAttributeTests.cs @@ -8,6 +8,7 @@ namespace Aspire.TestUtilities.Tests; +[Trait("Partition", "4")] public class UseCultureAttributeTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Utils/VolumeNameGeneratorTests.cs b/tests/Aspire.Hosting.Tests/Utils/VolumeNameGeneratorTests.cs index 82f153e2abb..942206896d3 100644 --- a/tests/Aspire.Hosting.Tests/Utils/VolumeNameGeneratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/VolumeNameGeneratorTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests.Utils; +[Trait("Partition", "4")] public class VolumeNameGeneratorTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/Utils/WithAnnotationTests.cs b/tests/Aspire.Hosting.Tests/Utils/WithAnnotationTests.cs index 5c4978ce470..72c20ef8aa4 100644 --- a/tests/Aspire.Hosting.Tests/Utils/WithAnnotationTests.cs +++ b/tests/Aspire.Hosting.Tests/Utils/WithAnnotationTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests.Utils; +[Trait("Partition", "4")] public class WithAnnotationTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/ValueSnapshotTests.cs b/tests/Aspire.Hosting.Tests/ValueSnapshotTests.cs index fc16de10c8f..9177b7d7bb8 100644 --- a/tests/Aspire.Hosting.Tests/ValueSnapshotTests.cs +++ b/tests/Aspire.Hosting.Tests/ValueSnapshotTests.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "2")] public class ValueSnapshotTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs b/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs index efd29cbcc82..20805aa64cb 100644 --- a/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs +++ b/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests.VersionChecking; +[Trait("Partition", "4")] public class PackageUpdateHelpersTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs b/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs index 8e236df3f5c..75673e03cc3 100644 --- a/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs @@ -16,6 +16,7 @@ namespace Aspire.Hosting.Tests.VersionChecking; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +[Trait("Partition", "4")] public class VersionCheckServiceTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/WaitForTests.cs b/tests/Aspire.Hosting.Tests/WaitForTests.cs index cd4212e5d36..2ac15285a1a 100644 --- a/tests/Aspire.Hosting.Tests/WaitForTests.cs +++ b/tests/Aspire.Hosting.Tests/WaitForTests.cs @@ -12,6 +12,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class WaitForTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/WithCertificateAuthorityCollection.cs b/tests/Aspire.Hosting.Tests/WithCertificateAuthorityCollection.cs index 88354665458..954e09bc20c 100644 --- a/tests/Aspire.Hosting.Tests/WithCertificateAuthorityCollection.cs +++ b/tests/Aspire.Hosting.Tests/WithCertificateAuthorityCollection.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class WithCertificateAuthorityCollectionTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/WithEndpointTests.cs b/tests/Aspire.Hosting.Tests/WithEndpointTests.cs index 2bf1b1d186a..a12b97d4b89 100644 --- a/tests/Aspire.Hosting.Tests/WithEndpointTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEndpointTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class WithEndpointTests { // copied from /src/Shared/StringComparers.cs to avoid ambiguous reference since StringComparers exists internally in multiple Hosting assemblies. diff --git a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs index 18b57208c57..086b5f427f1 100644 --- a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class WithEnvironmentTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs index e0659ee6e79..e338e906361 100644 --- a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs @@ -11,6 +11,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "6")] public class WithHttpCommandTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Hosting.Tests/WithIconNameTests.cs b/tests/Aspire.Hosting.Tests/WithIconNameTests.cs index e7f49c84d95..63e4b423f3a 100644 --- a/tests/Aspire.Hosting.Tests/WithIconNameTests.cs +++ b/tests/Aspire.Hosting.Tests/WithIconNameTests.cs @@ -5,6 +5,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class WithIconNameTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/WithMcpServerTests.cs b/tests/Aspire.Hosting.Tests/WithMcpServerTests.cs index 121aa29752b..df4c0b3eec3 100644 --- a/tests/Aspire.Hosting.Tests/WithMcpServerTests.cs +++ b/tests/Aspire.Hosting.Tests/WithMcpServerTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class WithMcpServerTests { [Fact] diff --git a/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs b/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs index ad2f2467492..534853f2e83 100644 --- a/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs +++ b/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "3")] public class WithOtlpExporterTests { [InlineData(default, "http://localhost:8889", null, "http://localhost:8889", "grpc")] diff --git a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs index 3ab0bb97b03..a750d7ed811 100644 --- a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs @@ -7,6 +7,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class WithReferenceTests { [Theory] diff --git a/tests/Aspire.Hosting.Tests/WithUrlsTests.cs b/tests/Aspire.Hosting.Tests/WithUrlsTests.cs index 5cda425da09..e68697b18f0 100644 --- a/tests/Aspire.Hosting.Tests/WithUrlsTests.cs +++ b/tests/Aspire.Hosting.Tests/WithUrlsTests.cs @@ -11,6 +11,7 @@ namespace Aspire.Hosting.Tests; +[Trait("Partition", "5")] public class WithUrlsTests(ITestOutputHelper testOutputHelper) { [Fact] diff --git a/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj b/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj index 3560642d7b1..4943f5ac76c 100644 --- a/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj +++ b/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj @@ -15,14 +15,6 @@ false false - - ./dotnet.sh build "%24(pwd)/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/AzureFunctionsEndToEnd.Functions.csproj" -c $(Configuration) /p:SkipUnstableEmulators=true /p:CI=false && - staging-archive\ $(TestArchiveTestsDirForBuildOnHelixTests) diff --git a/tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj b/tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj index cfd44eedfd5..c724a0fb0da 100644 --- a/tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj +++ b/tests/Aspire.Templates.Tests/Aspire.Templates.Tests.csproj @@ -13,8 +13,21 @@ true true - true - Aspire.Templates.Tests + true + Aspire.Templates.Tests + + + true + true + true + + + false + false + + + 20m + 15m false + @@ -26,8 +27,7 @@ Condition="'$(PrepareForHelix)' == 'true'" /> - + $(OutDir) @@ -41,46 +41,71 @@ Overwrite="true" /> - - + + - + - - - - + - <_Regex>^\s*($(ExtractTestClassNamesPrefix)[^\($]+) + + <_DiscoveryScriptPath>$(RepoRoot)eng\scripts\split-test-projects-for-ci.ps1 + + + <_TestPartitionsJsonAbsolutePath>$([MSBuild]::NormalizePath('$(RepoRoot)', '$(TestArchiveTestsDir)$(MSBuildProjectName).tests-partitions.json')) + - - <_TestLines0 Include="$([System.Text.RegularExpressions.Regex]::Match('%(_ListOfTestsLines.Identity)', '$(_Regex)'))" /> - - - - - + + + + + - + + <_PwshCommand>pwsh + <_TestAssemblyPath>$(TargetDir)$(TargetFileName) + <_DiscoveryCommand>$(_PwshCommand) -NoProfile -ExecutionPolicy Bypass -File "$(_DiscoveryScriptPath)" + <_DiscoveryCommand>$(_DiscoveryCommand) -TestAssemblyPath "$(_TestAssemblyPath)" + <_DiscoveryCommand>$(_DiscoveryCommand) -RunCommand "$(RunCommand)" + <_DiscoveryCommand>$(_DiscoveryCommand) -TestClassNamePrefixForCI "$(TestClassNamePrefixForCI)" + <_DiscoveryCommand>$(_DiscoveryCommand) -TestPartitionsJsonFile "$(_TestPartitionsJsonAbsolutePath)" + <_DiscoveryCommand>$(_DiscoveryCommand) -RepoRoot "$(RepoRoot.TrimEnd('\'))" + - - + - - - - - - + + + + + + + + + + true + + <_ShouldArchiveForHelixRun Condition="'$(IsAzdoHelixRunner)' == 'true' and + (($([MSBuild]::IsOSPlatform('Windows')) and'$(RunOnAzdoHelixWindows)' == 'true') + or ($([MSBuild]::IsOSPlatform('Linux')) and '$(RunOnAzdoHelixLinux)' == 'true'))">true + + <_ShouldArchiveForGithubActionsRun Condition="'$(IsGitHubActionsRunner)' == 'true' and '$(RequiresNugets)' == 'true'">true + + <_ShouldArchiveTests + Condition=" '$(IsTestProject)' == 'true' and '$(ArchiveTests)' == 'true' and + ('$(_ShouldArchiveForHelixRun)' == 'true' or '$(_ShouldArchiveForGithubActionsRun)' == 'true') and + '$(IsTestUtilityProject)' != 'true' and '$(IsCrossTargetingBuild)' != 'true'">true + diff --git a/tests/Infrastructure.Tests/ExtractTestPartitions/ExtractTestPartitionsFixture.cs b/tests/Infrastructure.Tests/ExtractTestPartitions/ExtractTestPartitionsFixture.cs new file mode 100644 index 00000000000..725d45e50aa --- /dev/null +++ b/tests/Infrastructure.Tests/ExtractTestPartitions/ExtractTestPartitionsFixture.cs @@ -0,0 +1,79 @@ +// 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; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Class fixture that builds the ExtractTestPartitions tool once before all tests run. +/// Individual tests can then use dotnet run --no-build for faster execution. +/// +public sealed class ExtractTestPartitionsFixture : IAsyncLifetime +{ + public string ToolProjectPath { get; private set; } = string.Empty; + + public async ValueTask InitializeAsync() + { + ToolProjectPath = GetToolProjectPath(); + + // Build the tool once before all tests + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"build \"{ToolProjectPath}\" --restore", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = psi }; + process.Start(); + + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + await process.WaitForExitAsync(cts.Token); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to build ExtractTestPartitions tool. Exit code: {process.ExitCode}\n" + + $"stdout:\n{stdout}\n" + + $"stderr:\n{stderr}"); + } + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private static string GetToolProjectPath() + { + var repoRoot = FindRepoRoot(); + var projectPath = Path.Combine(repoRoot, "tools", "ExtractTestPartitions", "ExtractTestPartitions.csproj"); + + if (!File.Exists(projectPath)) + { + throw new InvalidOperationException( + $"ExtractTestPartitions project not found at {projectPath}."); + } + + return projectPath; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find repository root (looking for Aspire.slnx)"); + } +} diff --git a/tests/Infrastructure.Tests/ExtractTestPartitions/ExtractTestPartitionsTests.cs b/tests/Infrastructure.Tests/ExtractTestPartitions/ExtractTestPartitionsTests.cs new file mode 100644 index 00000000000..05e6d355efe --- /dev/null +++ b/tests/Infrastructure.Tests/ExtractTestPartitions/ExtractTestPartitionsTests.cs @@ -0,0 +1,345 @@ +// 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; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for the ExtractTestPartitions tool. +/// +public class ExtractTestPartitionsTests : IClassFixture, IDisposable +{ + private readonly TestTempDirectory _tempDir = new(); + private readonly ExtractTestPartitionsFixture _fixture; + private readonly ITestOutputHelper _output; + + public ExtractTestPartitionsTests(ExtractTestPartitionsFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + public void Dispose() => _tempDir.Dispose(); + + [Fact] + public async Task ExtractsPartitionTraits() + { + // Arrange + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestClass1", "PartitionA"), + ("TestClass2", "PartitionB")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.True(File.Exists(outputFile), "Output file should be created"); + + var partitions = File.ReadAllLines(outputFile); + Assert.Equal(2, partitions.Length); + Assert.Contains("PartitionA", partitions); + Assert.Contains("PartitionB", partitions); + } + + [Fact] + public async Task IgnoresCollectionAttributes() + { + // Arrange - Collection attributes are for shared fixtures, not CI splitting + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithCollections( + assemblyPath, + ("TestClass1", "CollectionX"), + ("TestClass2", "CollectionY")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.False(File.Exists(outputFile), "Output file should NOT be created for Collection-only attributes"); + Assert.Contains("No partitions found", result.Output); + } + + [Fact] + public async Task ExtractsOnlyTraitPartitionsFromMixedAttributes() + { + // Arrange + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithMixedAttributes( + assemblyPath, + partitions: [("PartitionTest1", "PartA")], + collections: [("CollectionTest1", "CollB")]); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert - Only Trait("Partition") entries are extracted, not Collection attributes + Assert.Equal(0, result.ExitCode); + Assert.True(File.Exists(outputFile), "Output file should be created"); + + var partitions = File.ReadAllLines(outputFile); + Assert.Single(partitions); + Assert.Contains("PartA", partitions); + } + + [Fact] + public async Task ReturnsEmptyForNoAttributes() + { + // Arrange + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithNoAttributes(assemblyPath); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.False(File.Exists(outputFile), "Output file should NOT be created when no partitions found"); + Assert.Contains("No partitions found", result.Output); + } + + [Fact] + public async Task SortsPartitionsAlphabetically() + { + // Arrange + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestZ", "Zebra"), + ("TestA", "Apple"), + ("TestM", "Mango")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + var partitions = File.ReadAllLines(outputFile); + Assert.Equal(3, partitions.Length); + Assert.Equal("Apple", partitions[0]); + Assert.Equal("Mango", partitions[1]); + Assert.Equal("Zebra", partitions[2]); + } + + [Fact] + public async Task DeduplicatesPartitions() + { + // Arrange - same partition name with different casing + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestClass1", "PartitionA"), + ("TestClass2", "partitiona"), // lowercase variant + ("TestClass3", "PARTITIONA")); // uppercase variant + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + var partitions = File.ReadAllLines(outputFile); + // Should be deduplicated case-insensitively - only one entry + Assert.Single(partitions); + } + + [Fact] + public async Task HandlesInvalidAssemblyPath() + { + // Arrange + var nonExistentPath = Path.Combine(_tempDir.Path, "DoesNotExist.dll"); + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(nonExistentPath, outputFile); + + // Assert + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("not found", result.Output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task HandlesInvalidArguments() + { + // Act - run with no arguments + var result = await RunToolRaw(); + + // Assert + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("Usage:", result.Output); + } + + [Fact] + public async Task CreatesOutputDirectory() + { + // Arrange + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestClass1", "PartitionA")); + + // Output in nested directory that doesn't exist + var outputFile = Path.Combine(_tempDir.Path, "nested", "dir", "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.True(File.Exists(outputFile), "Output file should be created in nested directory"); + } + + [Fact] + public async Task ExtractsPartitionsFromNestedTypes() + { + // Arrange - Test classes can be nested (Outer+Inner pattern) + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithNestedTypePartitions( + assemblyPath, + ("OuterClass", "InnerClass", "NestedPartition")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.True(File.Exists(outputFile), "Output file should be created"); + + var partitions = File.ReadAllLines(outputFile); + Assert.Contains("NestedPartition", partitions); + } + + [Fact] + public async Task IgnoresEmptyPartitionNames() + { + // Arrange - Empty/whitespace partition names should be ignored + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestClass1", "ValidPartition"), + ("TestClass2", ""), // Empty name + ("TestClass3", " ")); // Whitespace-only name + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.True(File.Exists(outputFile), "Output file should be created"); + + var partitions = File.ReadAllLines(outputFile); + Assert.Single(partitions); + Assert.Equal("ValidPartition", partitions[0]); + } + + [Fact] + public async Task IgnoresNonPartitionTraits() + { + // Arrange - Only Traits with key "Partition" should be extracted + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithNonPartitionTraits( + assemblyPath, + ("TestClass1", "Partition", "ShouldInclude"), + ("TestClass2", "Category", "ShouldIgnore"), + ("TestClass3", "OtherKey", "AlsoIgnore")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.txt"); + + // Act + var result = await RunTool(assemblyPath, outputFile); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.True(File.Exists(outputFile), "Output file should be created"); + + var partitions = File.ReadAllLines(outputFile); + Assert.Single(partitions); + Assert.Equal("ShouldInclude", partitions[0]); + } + + private async Task RunTool(string assemblyPath, string outputFile) + { + return await RunToolRaw("--assembly-path", assemblyPath, "--output-file", outputFile); + } + + private async Task RunToolRaw(params string[] args) + { + // Use 'dotnet run --no-build' since the fixture already built the tool + var psi = new ProcessStartInfo + { + FileName = "dotnet", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + psi.ArgumentList.Add("run"); + psi.ArgumentList.Add("--no-build"); + psi.ArgumentList.Add("--project"); + psi.ArgumentList.Add(_fixture.ToolProjectPath); + + if (args.Length > 0) + { + psi.ArgumentList.Add("--"); + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + } + + _output.WriteLine($"Running: {psi.FileName} {string.Join(" ", psi.ArgumentList)}"); + + using var process = new Process { StartInfo = psi }; + var outputLines = new List(); + + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + outputLines.Add(e.Data); + _output.WriteLine($"[stdout] {e.Data}"); + } + }; + + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + outputLines.Add(e.Data); + _output.WriteLine($"[stderr] {e.Data}"); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + await process.WaitForExitAsync(cts.Token); + + return new ToolResult(process.ExitCode, string.Join(Environment.NewLine, outputLines)); + } + + private sealed record ToolResult(int ExitCode, string Output); +} diff --git a/tests/Infrastructure.Tests/ExtractTestPartitions/MockAssemblyBuilder.cs b/tests/Infrastructure.Tests/ExtractTestPartitions/MockAssemblyBuilder.cs new file mode 100644 index 00000000000..60fa8c671c5 --- /dev/null +++ b/tests/Infrastructure.Tests/ExtractTestPartitions/MockAssemblyBuilder.cs @@ -0,0 +1,283 @@ +// 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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace Infrastructure.Tests; + +/// +/// Builds mock test assemblies dynamically using Roslyn for testing ExtractTestPartitions. +/// +public static class MockAssemblyBuilder +{ + /// + /// Creates an assembly with test classes having [Trait("Partition", "name")] attributes. + /// + public static string CreateAssemblyWithPartitions( + string outputPath, + params (string ClassName, string PartitionName)[] partitions) + { + var code = new StringBuilder(); + code.AppendLine("using System;"); + code.AppendLine(); + code.AppendLine("[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]"); + code.AppendLine("public class TraitAttribute : Attribute"); + code.AppendLine("{"); + code.AppendLine(" public TraitAttribute(string name, string value) { Name = name; Value = value; }"); + code.AppendLine(" public string Name { get; }"); + code.AppendLine(" public string Value { get; }"); + code.AppendLine("}"); + code.AppendLine(); + + foreach (var (className, partitionName) in partitions) + { + code.AppendLine($"[Trait(\"Partition\", \"{partitionName}\")]"); + code.AppendLine($"public class {className}"); + code.AppendLine("{"); + code.AppendLine(" public void TestMethod() { }"); + code.AppendLine("}"); + code.AppendLine(); + } + + return CompileAssembly(outputPath, code.ToString()); + } + + /// + /// Creates an assembly with test classes having [Collection("name")] attributes. + /// + public static string CreateAssemblyWithCollections( + string outputPath, + params (string ClassName, string CollectionName)[] collections) + { + var code = new StringBuilder(); + code.AppendLine("using System;"); + code.AppendLine(); + code.AppendLine("[AttributeUsage(AttributeTargets.Class)]"); + code.AppendLine("public class CollectionAttribute : Attribute"); + code.AppendLine("{"); + code.AppendLine(" public CollectionAttribute(string name) { Name = name; }"); + code.AppendLine(" public string Name { get; }"); + code.AppendLine("}"); + code.AppendLine(); + + foreach (var (className, collectionName) in collections) + { + code.AppendLine($"[Collection(\"{collectionName}\")]"); + code.AppendLine($"public class {className}"); + code.AppendLine("{"); + code.AppendLine(" public void TestMethod() { }"); + code.AppendLine("}"); + code.AppendLine(); + } + + return CompileAssembly(outputPath, code.ToString()); + } + + /// + /// Creates an assembly with test classes having both [Trait] and [Collection] attributes. + /// + public static string CreateAssemblyWithMixedAttributes( + string outputPath, + (string ClassName, string PartitionName)[] partitions, + (string ClassName, string CollectionName)[] collections) + { + var code = new StringBuilder(); + code.AppendLine("using System;"); + code.AppendLine(); + code.AppendLine("[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]"); + code.AppendLine("public class TraitAttribute : Attribute"); + code.AppendLine("{"); + code.AppendLine(" public TraitAttribute(string name, string value) { Name = name; Value = value; }"); + code.AppendLine(" public string Name { get; }"); + code.AppendLine(" public string Value { get; }"); + code.AppendLine("}"); + code.AppendLine(); + code.AppendLine("[AttributeUsage(AttributeTargets.Class)]"); + code.AppendLine("public class CollectionAttribute : Attribute"); + code.AppendLine("{"); + code.AppendLine(" public CollectionAttribute(string name) { Name = name; }"); + code.AppendLine(" public string Name { get; }"); + code.AppendLine("}"); + code.AppendLine(); + + foreach (var (className, partitionName) in partitions) + { + code.AppendLine($"[Trait(\"Partition\", \"{partitionName}\")]"); + code.AppendLine($"public class {className}"); + code.AppendLine("{"); + code.AppendLine(" public void TestMethod() { }"); + code.AppendLine("}"); + code.AppendLine(); + } + + foreach (var (className, collectionName) in collections) + { + code.AppendLine($"[Collection(\"{collectionName}\")]"); + code.AppendLine($"public class {className}"); + code.AppendLine("{"); + code.AppendLine(" public void TestMethod() { }"); + code.AppendLine("}"); + code.AppendLine(); + } + + return CompileAssembly(outputPath, code.ToString()); + } + + /// + /// Creates an assembly with test classes having no partition/collection attributes. + /// + public static string CreateAssemblyWithNoAttributes(string outputPath, params string[] classNames) + { + var code = new StringBuilder(); + code.AppendLine("using System;"); + code.AppendLine(); + + var names = classNames.Length > 0 ? classNames : new[] { "TestClass1", "TestClass2" }; + + foreach (var className in names) + { + code.AppendLine($"public class {className}"); + code.AppendLine("{"); + code.AppendLine(" public void TestMethod() { }"); + code.AppendLine("}"); + code.AppendLine(); + } + + return CompileAssembly(outputPath, code.ToString()); + } + + /// + /// Creates an assembly with classes having duplicate partition names (different casing). + /// + public static string CreateAssemblyWithDuplicatePartitions( + string outputPath, + params (string ClassName, string PartitionName)[] partitions) + { + // Same as CreateAssemblyWithPartitions - the deduplication is in ExtractTestPartitions + return CreateAssemblyWithPartitions(outputPath, partitions); + } + + /// + /// Creates an assembly with a nested class having a partition attribute. + /// + public static string CreateAssemblyWithNestedTypePartitions( + string outputPath, + params (string OuterClassName, string InnerClassName, string PartitionName)[] nestedPartitions) + { + var code = new StringBuilder(); + code.AppendLine("using System;"); + code.AppendLine(); + code.AppendLine("[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]"); + code.AppendLine("public class TraitAttribute : Attribute"); + code.AppendLine("{"); + code.AppendLine(" public TraitAttribute(string name, string value) { Name = name; Value = value; }"); + code.AppendLine(" public string Name { get; }"); + code.AppendLine(" public string Value { get; }"); + code.AppendLine("}"); + code.AppendLine(); + + foreach (var (outerClassName, innerClassName, partitionName) in nestedPartitions) + { + code.AppendLine($"public class {outerClassName}"); + code.AppendLine("{"); + code.AppendLine($" [Trait(\"Partition\", \"{partitionName}\")]"); + code.AppendLine($" public class {innerClassName}"); + code.AppendLine(" {"); + code.AppendLine(" public void TestMethod() { }"); + code.AppendLine(" }"); + code.AppendLine("}"); + code.AppendLine(); + } + + return CompileAssembly(outputPath, code.ToString()); + } + + /// + /// Creates an assembly with Trait attributes of various keys (not just "Partition"). + /// + public static string CreateAssemblyWithNonPartitionTraits( + string outputPath, + params (string ClassName, string TraitKey, string TraitValue)[] traits) + { + var code = new StringBuilder(); + code.AppendLine("using System;"); + code.AppendLine(); + code.AppendLine("[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]"); + code.AppendLine("public class TraitAttribute : Attribute"); + code.AppendLine("{"); + code.AppendLine(" public TraitAttribute(string name, string value) { Name = name; Value = value; }"); + code.AppendLine(" public string Name { get; }"); + code.AppendLine(" public string Value { get; }"); + code.AppendLine("}"); + code.AppendLine(); + + foreach (var (className, traitKey, traitValue) in traits) + { + code.AppendLine($"[Trait(\"{traitKey}\", \"{traitValue}\")]"); + code.AppendLine($"public class {className}"); + code.AppendLine("{"); + code.AppendLine(" public void TestMethod() { }"); + code.AppendLine("}"); + code.AppendLine(); + } + + return CompileAssembly(outputPath, code.ToString()); + } + + private static string CompileAssembly(string outputPath, string code) + { + var syntaxTree = CSharpSyntaxTree.ParseText(code); + + var references = new List + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location) + }; + + // Add netstandard reference for compilation + var netstandardPath = Path.Combine( + Path.GetDirectoryName(typeof(object).Assembly.Location)!, + "netstandard.dll"); + if (File.Exists(netstandardPath)) + { + references.Add(MetadataReference.CreateFromFile(netstandardPath)); + } + + // Add System.Runtime reference + var runtimePath = Path.Combine( + Path.GetDirectoryName(typeof(object).Assembly.Location)!, + "System.Runtime.dll"); + if (File.Exists(runtimePath)) + { + references.Add(MetadataReference.CreateFromFile(runtimePath)); + } + + var compilation = CSharpCompilation.Create( + Path.GetFileNameWithoutExtension(outputPath), + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var outputDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + EmitResult result = compilation.Emit(outputPath); + + if (!result.Success) + { + var errors = string.Join(Environment.NewLine, + result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString())); + throw new InvalidOperationException($"Failed to compile mock assembly:{Environment.NewLine}{errors}"); + } + + return outputPath; + } +} diff --git a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj new file mode 100644 index 00000000000..bb97a42d402 --- /dev/null +++ b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj @@ -0,0 +1,33 @@ + + + + $(DefaultTargetFramework) + enable + enable + true + + + false + false + + + false + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Infrastructure.Tests/PowerShellScripts/BuildTestMatrixTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/BuildTestMatrixTests.cs new file mode 100644 index 00000000000..09deb05a9f5 --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/BuildTestMatrixTests.cs @@ -0,0 +1,474 @@ +// 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; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for eng/scripts/build-test-matrix.ps1 +/// +public class BuildTestMatrixTests : IDisposable +{ + private readonly TestTempDirectory _tempDir = new(); + private readonly string _scriptPath; + private readonly ITestOutputHelper _output; + + public BuildTestMatrixTests(ITestOutputHelper output) + { + _output = output; + _scriptPath = Path.Combine(FindRepoRoot(), "eng", "scripts", "build-test-matrix.ps1"); + } + + public void Dispose() => _tempDir.Dispose(); + + [Fact] + [RequiresTools(["pwsh"])] + public async Task GeneratesMatrixFromSingleProject() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "MyProject.tests-metadata.json"), + projectName: "MyProject", + testProjectPath: "tests/MyProject/MyProject.csproj", + shortName: "MyProj"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful("build-test-matrix.ps1 failed"); + + var matrix = ParseCanonicalMatrix(outputFile); + var entry = Assert.Single(matrix.Tests); + Assert.Equal("MyProject", entry.ProjectName); + Assert.Equal("MyProj", entry.Name); + Assert.Equal("regular", entry.Type); + Assert.False(entry.RequiresNugets); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task GeneratesMatrixFromMultipleProjects() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "ProjectA.tests-metadata.json"), + projectName: "ProjectA", + testProjectPath: "tests/ProjectA/ProjectA.csproj"); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "ProjectB.tests-metadata.json"), + projectName: "ProjectB", + testProjectPath: "tests/ProjectB/ProjectB.csproj"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + Assert.Equal(2, matrix.Tests.Length); + Assert.Contains(matrix.Tests, e => e.ProjectName == "ProjectA"); + Assert.Contains(matrix.Tests, e => e.ProjectName == "ProjectB"); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task GeneratesPartitionEntries() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateSplitTestsMetadataJson( + Path.Combine(artifactsDir, "SplitProject.tests-metadata.json"), + projectName: "SplitProject", + testProjectPath: "tests/SplitProject/SplitProject.csproj", + shortName: "Split"); + + TestDataBuilder.CreateTestsPartitionsJson( + Path.Combine(artifactsDir, "SplitProject.tests-partitions.json"), + "PartitionA", "PartitionB"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + // Should have 3 entries: PartitionA, PartitionB, and uncollected + Assert.Equal(3, matrix.Tests.Length); + + var partitionA = matrix.Tests.FirstOrDefault(e => e.Name == "Split-PartitionA"); + Assert.NotNull(partitionA); + Assert.Equal("collection", partitionA.Type); + Assert.Contains("--filter-trait", partitionA.ExtraTestArgs); + Assert.Contains("Partition=PartitionA", partitionA.ExtraTestArgs); + + var uncollected = matrix.Tests.FirstOrDefault(e => e.Name == "Split"); + Assert.NotNull(uncollected); + Assert.Contains("--filter-not-trait", uncollected.ExtraTestArgs); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task GeneratesClassEntries() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateSplitTestsMetadataJson( + Path.Combine(artifactsDir, "ClassSplitProject.tests-metadata.json"), + projectName: "ClassSplitProject", + testProjectPath: "tests/ClassSplitProject/ClassSplitProject.csproj", + shortName: "ClassSplit"); + + TestDataBuilder.CreateClassBasedPartitionsJson( + Path.Combine(artifactsDir, "ClassSplitProject.tests-partitions.json"), + "MyNamespace.TestClassA", "MyNamespace.TestClassB"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + Assert.Equal(2, matrix.Tests.Length); + + var classA = matrix.Tests.FirstOrDefault(e => e.Name == "ClassSplit-TestClassA"); + Assert.NotNull(classA); + Assert.Equal("class", classA.Type); + Assert.Contains("--filter-class", classA.ExtraTestArgs); + Assert.Contains("MyNamespace.TestClassA", classA.ExtraTestArgs); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task AppliesDefaultTimeouts() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + // Create metadata without explicit timeouts + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "NoTimeouts.tests-metadata.json"), + projectName: "NoTimeouts", + testProjectPath: "tests/NoTimeouts/NoTimeouts.csproj"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + var entry = Assert.Single(matrix.Tests); + Assert.Equal("20m", entry.TestSessionTimeout); + Assert.Equal("10m", entry.TestHangTimeout); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PreservesCustomTimeouts() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "CustomTimeouts.tests-metadata.json"), + projectName: "CustomTimeouts", + testProjectPath: "tests/CustomTimeouts/CustomTimeouts.csproj", + testSessionTimeout: "45m", + testHangTimeout: "15m"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + var entry = Assert.Single(matrix.Tests); + Assert.Equal("45m", entry.TestSessionTimeout); + Assert.Equal("15m", entry.TestHangTimeout); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PreservesRequiresNugetsProperty() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "NeedsNugets.tests-metadata.json"), + projectName: "NeedsNugets", + testProjectPath: "tests/NeedsNugets/NeedsNugets.csproj", + requiresNugets: true); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "NoNugets.tests-metadata.json"), + projectName: "NoNugets", + testProjectPath: "tests/NoNugets/NoNugets.csproj", + requiresNugets: false); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + Assert.Equal(2, matrix.Tests.Length); + Assert.Contains(matrix.Tests, e => e.ProjectName == "NeedsNugets" && e.RequiresNugets == true); + Assert.Contains(matrix.Tests, e => e.ProjectName == "NoNugets" && e.RequiresNugets == false); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task GeneratesCorrectFilterArgs() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateSplitTestsMetadataJson( + Path.Combine(artifactsDir, "FilterTest.tests-metadata.json"), + projectName: "FilterTest", + testProjectPath: "tests/FilterTest/FilterTest.csproj"); + + TestDataBuilder.CreateTestsPartitionsJson( + Path.Combine(artifactsDir, "FilterTest.tests-partitions.json"), + "MyPartition"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + var partitionEntry = matrix.Tests.First(e => e.Collection == "MyPartition"); + Assert.Equal("--filter-trait \"Partition=MyPartition\"", partitionEntry.ExtraTestArgs); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task CreatesEmptyMatrixWhenNoMetadataFiles() + { + // Arrange + var emptyArtifactsDir = Path.Combine(_tempDir.Path, "empty-artifacts"); + Directory.CreateDirectory(emptyArtifactsDir); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(emptyArtifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + Assert.Empty(matrix.Tests); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task UsesUncollectedTimeoutsForUncollectedEntry() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateSplitTestsMetadataJson( + Path.Combine(artifactsDir, "SplitProject.tests-metadata.json"), + projectName: "SplitProject", + testProjectPath: "tests/SplitProject/SplitProject.csproj", + shortName: "Split", + testSessionTimeout: "30m", + testHangTimeout: "15m", + uncollectedTestsSessionTimeout: "45m", + uncollectedTestsHangTimeout: "20m"); + + TestDataBuilder.CreateTestsPartitionsJson( + Path.Combine(artifactsDir, "SplitProject.tests-partitions.json"), + "PartitionA"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + + // The partitioned entry should have regular timeouts + var partitionEntry = matrix.Tests.FirstOrDefault(e => e.Name == "Split-PartitionA"); + Assert.NotNull(partitionEntry); + Assert.Equal("30m", partitionEntry.TestSessionTimeout); + Assert.Equal("15m", partitionEntry.TestHangTimeout); + + // The uncollected entry should have uncollected-specific timeouts + var uncollectedEntry = matrix.Tests.FirstOrDefault(e => e.Name == "Split"); + Assert.NotNull(uncollectedEntry); + Assert.Equal("45m", uncollectedEntry.TestSessionTimeout); + Assert.Equal("20m", uncollectedEntry.TestHangTimeout); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PassesRequiresTestSdkProperty() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "SdkProject.tests-metadata.json"), + projectName: "SdkProject", + testProjectPath: "tests/SdkProject/SdkProject.csproj", + requiresTestSdk: true); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + var entry = Assert.Single(matrix.Tests); + Assert.True(entry.RequiresTestSdk); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PreservesSupportedOSes() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "LinuxOnly.tests-metadata.json"), + projectName: "LinuxOnly", + testProjectPath: "tests/LinuxOnly/LinuxOnly.csproj", + supportedOSes: ["linux"]); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + var entry = Assert.Single(matrix.Tests); + Assert.Single(entry.SupportedOSes); + Assert.Contains("linux", entry.SupportedOSes); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task InheritsSupportedOSesToPartitionEntries() + { + // Arrange + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateSplitTestsMetadataJson( + Path.Combine(artifactsDir, "OsRestrictedSplit.tests-metadata.json"), + projectName: "OsRestrictedSplit", + testProjectPath: "tests/OsRestrictedSplit/OsRestrictedSplit.csproj", + shortName: "OsRestrict", + supportedOSes: ["windows", "linux"]); + + TestDataBuilder.CreateTestsPartitionsJson( + Path.Combine(artifactsDir, "OsRestrictedSplit.tests-partitions.json"), + "PartA"); + + var outputFile = Path.Combine(_tempDir.Path, "matrix.json"); + + // Act + var result = await RunScript(artifactsDir, outputFile); + + // Assert + result.EnsureSuccessful(); + + var matrix = ParseCanonicalMatrix(outputFile); + // Both the partition entry and uncollected entry should have the same supportedOSes + foreach (var entry in matrix.Tests) + { + Assert.Equal(2, entry.SupportedOSes.Length); + Assert.Contains("windows", entry.SupportedOSes); + Assert.Contains("linux", entry.SupportedOSes); + } + } + + private async Task RunScript(string artifactsDir, string outputFile) + { + using var cmd = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(2)); + + return await cmd.ExecuteAsync( + "-ArtifactsDir", $"\"{artifactsDir}\"", + "-OutputMatrixFile", $"\"{outputFile}\""); + } + + private static CanonicalMatrix ParseCanonicalMatrix(string path) + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Failed to parse matrix JSON"); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find repository root"); + } +} diff --git a/tests/Infrastructure.Tests/PowerShellScripts/ExpandTestMatrixGitHubTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/ExpandTestMatrixGitHubTests.cs new file mode 100644 index 00000000000..7908c5f9c9f --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/ExpandTestMatrixGitHubTests.cs @@ -0,0 +1,600 @@ +// 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; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for eng/scripts/expand-test-matrix-github.ps1 +/// +public class ExpandTestMatrixGitHubTests : IDisposable +{ + private readonly TestTempDirectory _tempDir = new(); + private readonly string _scriptPath; + private readonly ITestOutputHelper _output; + + public ExpandTestMatrixGitHubTests(ITestOutputHelper output) + { + _output = output; + _scriptPath = Path.Combine(FindRepoRoot(), "eng", "scripts", "expand-test-matrix-github.ps1"); + } + + public void Dispose() => _tempDir.Dispose(); + + [Fact] + [RequiresTools(["pwsh"])] + public async Task MapsWindowsToRunner() + { + // Arrange + var entry = TestDataBuilder.CreateMatrixEntry( + name: "TestProject", + projectName: "TestProject", + testProjectPath: "tests/TestProject/TestProject.csproj", + supportedOSes: ["windows"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + // Act + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + // Assert + result.EnsureSuccessful("expand-test-matrix-github.ps1 failed"); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Single(expanded.Include); + Assert.Equal("windows-latest", expanded.Include[0].RunsOn); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task MapsLinuxToRunner() + { + // Arrange + var entry = TestDataBuilder.CreateMatrixEntry( + name: "TestProject", + projectName: "TestProject", + testProjectPath: "tests/TestProject/TestProject.csproj", + supportedOSes: ["linux"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + // Act + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + // Assert + result.EnsureSuccessful(); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Single(expanded.Include); + Assert.Equal("ubuntu-latest", expanded.Include[0].RunsOn); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task MapsMacOSToRunner() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "TestProject", + projectName: "TestProject", + testProjectPath: "tests/TestProject/TestProject.csproj", + supportedOSes: ["macos"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Single(expanded.Include); + Assert.Equal("macos-latest", expanded.Include[0].RunsOn); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task ExpandsMultipleOSes() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "MultiOSProject", + projectName: "MultiOSProject", + testProjectPath: "tests/MultiOSProject/MultiOSProject.csproj", + supportedOSes: ["windows", "linux", "macos"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Equal(3, expanded.Include.Length); + Assert.Contains(expanded.Include, e => e.RunsOn == "windows-latest"); + Assert.Contains(expanded.Include, e => e.RunsOn == "ubuntu-latest"); + Assert.Contains(expanded.Include, e => e.RunsOn == "macos-latest"); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task GeneratesIncludeFormat() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "TestProject", + projectName: "TestProject", + testProjectPath: "tests/TestProject/TestProject.csproj", + supportedOSes: ["linux"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var json = File.ReadAllText(outputFile); + var document = JsonDocument.Parse(json); + Assert.True(document.RootElement.TryGetProperty("include", out var include)); + Assert.True( + include.ValueKind == JsonValueKind.Array || include.ValueKind == JsonValueKind.Object, + $"Expected include to be Array or Object, got {include.ValueKind}"); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task RemovesSupportedOSes() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "TestProject", + projectName: "TestProject", + testProjectPath: "tests/TestProject/TestProject.csproj", + supportedOSes: ["windows"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var json = File.ReadAllText(outputFile); + var document = JsonDocument.Parse(json); + var include = document.RootElement.GetProperty("include"); + + var firstEntry = include.ValueKind == JsonValueKind.Array + ? include.EnumerateArray().First() + : include; + + Assert.False(firstEntry.TryGetProperty("supportedOSes", out _), + "supportedOSes should be removed from expanded entries"); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task AddsRunsOnProperty() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "TestProject", + projectName: "TestProject", + testProjectPath: "tests/TestProject/TestProject.csproj", + supportedOSes: ["linux"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var json = File.ReadAllText(outputFile); + var document = JsonDocument.Parse(json); + var include = document.RootElement.GetProperty("include"); + + var firstEntry = include.ValueKind == JsonValueKind.Array + ? include.EnumerateArray().First() + : include; + + Assert.True(firstEntry.TryGetProperty("runs-on", out var runsOn), + "runs-on should be added to expanded entries"); + Assert.Equal("ubuntu-latest", runsOn.GetString()); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PreservesAllEntryProperties() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "FullProject", + projectName: "FullProject", + testProjectPath: "tests/FullProject/FullProject.csproj", + type: "collection", + shortname: "Full", + workitemprefix: "FullProject_Part", + collection: "MyPartition", + extraTestArgs: "--filter-trait \"Partition=MyPartition\"", + testSessionTimeout: "30m", + testHangTimeout: "15m", + supportedOSes: ["linux"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var expanded = ParseGitHubMatrix(outputFile); + var expandedEntry = Assert.Single(expanded.Include); + + Assert.Equal("FullProject", expandedEntry.ProjectName); + Assert.Equal("FullProject", expandedEntry.Name); + Assert.Equal("collection", expandedEntry.Type); + Assert.Equal("Full", expandedEntry.Shortname); + Assert.Equal("FullProject_Part", expandedEntry.Workitemprefix); + Assert.Equal("MyPartition", expandedEntry.Collection); + Assert.Equal("--filter-trait \"Partition=MyPartition\"", expandedEntry.ExtraTestArgs); + Assert.Equal("30m", expandedEntry.TestSessionTimeout); + Assert.Equal("15m", expandedEntry.TestHangTimeout); + Assert.Equal("ubuntu-latest", expandedEntry.RunsOn); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task HandlesEmptyMatrix() + { + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Empty(expanded.Include); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenCanonicalMatrixNotFound() + { + var nonExistentFile = Path.Combine(_tempDir.Path, "does-not-exist.json"); + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(nonExistentFile, outputMatrixFile: outputFile); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("not found", result.Output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task DefaultsToAllOSesWhenSupportedOSesEmpty() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "NoOsSpecified", + projectName: "NoOsSpecified", + testProjectPath: "tests/NoOsSpecified/NoOsSpecified.csproj", + supportedOSes: []); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Equal(3, expanded.Include.Length); + Assert.Contains(expanded.Include, e => e.RunsOn == "windows-latest"); + Assert.Contains(expanded.Include, e => e.RunsOn == "ubuntu-latest"); + Assert.Contains(expanded.Include, e => e.RunsOn == "macos-latest"); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task WarnsOnInvalidOS() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "BadOs", + projectName: "BadOs", + testProjectPath: "tests/BadOs/BadOs.csproj", + supportedOSes: ["linux", "invalid-os"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + Assert.Contains("invalid", result.Output.ToLowerInvariant()); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Single(expanded.Include); + Assert.Equal("ubuntu-latest", expanded.Include[0].RunsOn); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task HandlesOSNamesWithDifferentCasing() + { + var entry = TestDataBuilder.CreateMatrixEntry( + name: "CasedOs", + projectName: "CasedOs", + testProjectPath: "tests/CasedOs/CasedOs.csproj", + supportedOSes: ["WINDOWS", "Linux", "MacOS"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [entry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Equal(3, expanded.Include.Length); + Assert.Contains(expanded.Include, e => e.RunsOn == "windows-latest"); + Assert.Contains(expanded.Include, e => e.RunsOn == "ubuntu-latest"); + Assert.Contains(expanded.Include, e => e.RunsOn == "macos-latest"); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task SplitTestsGoToNoNugetsCategory() + { + var splitEntry = TestDataBuilder.CreateMatrixEntry( + name: "SplitProject", + projectName: "SplitProject", + testProjectPath: "tests/SplitProject/SplitProject.csproj", + supportedOSes: ["linux"]); + splitEntry.SplitTests = true; + + var regularEntry = TestDataBuilder.CreateMatrixEntry( + name: "RegularProject", + projectName: "RegularProject", + testProjectPath: "tests/RegularProject/RegularProject.csproj", + supportedOSes: ["linux"]); + + var canonicalMatrix = Path.Combine(_tempDir.Path, "canonical.json"); + TestDataBuilder.CreateCanonicalMatrixJson(canonicalMatrix, tests: [splitEntry, regularEntry]); + + var outputFile = Path.Combine(_tempDir.Path, "expanded.json"); + + var result = await RunScript(canonicalMatrix, outputMatrixFile: outputFile); + + result.EnsureSuccessful(); + + var expanded = ParseGitHubMatrix(outputFile); + Assert.Equal(2, expanded.Include.Length); + Assert.Contains(expanded.Include, e => e.ProjectName == "SplitProject"); + Assert.Contains(expanded.Include, e => e.ProjectName == "RegularProject"); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FullPipeline_SplitTestsExpandPerOS() + { + // Validates the full pipeline: build-test-matrix → expand-test-matrix-github → split-test-matrix-by-deps + var artifactsDir = Path.Combine(_tempDir.Path, "artifacts"); + Directory.CreateDirectory(artifactsDir); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "RegularProject.tests-metadata.json"), + projectName: "RegularProject", + testProjectPath: "tests/RegularProject/RegularProject.csproj", + shortName: "Regular"); + + TestDataBuilder.CreateSplitTestsMetadataJson( + Path.Combine(artifactsDir, "SplitMultiOS.tests-metadata.json"), + projectName: "SplitMultiOS", + testProjectPath: "tests/SplitMultiOS/SplitMultiOS.csproj", + shortName: "SplitMultiOS", + supportedOSes: ["windows", "linux", "macos"]); + + TestDataBuilder.CreateClassBasedPartitionsJson( + Path.Combine(artifactsDir, "SplitMultiOS.tests-partitions.json"), + "Namespace.ClassA", "Namespace.ClassB"); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "LinuxE2E.tests-metadata.json"), + projectName: "LinuxE2E", + testProjectPath: "tests/LinuxE2E/LinuxE2E.csproj", + shortName: "LinuxE2E", + requiresNugets: true, + supportedOSes: ["linux"]); + + TestDataBuilder.CreateTestsMetadataJson( + Path.Combine(artifactsDir, "CliE2E.tests-metadata.json"), + projectName: "CliE2E", + testProjectPath: "tests/CliE2E/CliE2E.csproj", + shortName: "CliE2E", + requiresNugets: true, + requiresCliArchive: true, + supportedOSes: ["linux"]); + + // Run build-test-matrix.ps1 + var buildMatrixScript = Path.Combine(FindRepoRoot(), "eng", "scripts", "build-test-matrix.ps1"); + var canonicalFile = Path.Combine(_tempDir.Path, "canonical.json"); + + using var buildCmd = new PowerShellCommand(buildMatrixScript, _output) + .WithTimeout(TimeSpan.FromMinutes(2)); + var buildResult = await buildCmd.ExecuteAsync( + "-ArtifactsDir", $"\"{artifactsDir}\"", + "-OutputMatrixFile", $"\"{canonicalFile}\""); + buildResult.EnsureSuccessful("build-test-matrix.ps1 failed"); + + // Run expand-test-matrix-github.ps1 → single output file + var expandedFile = Path.Combine(_tempDir.Path, "expanded.json"); + var expandResult = await RunScript(canonicalFile, outputMatrixFile: expandedFile); + expandResult.EnsureSuccessful("expand-test-matrix-github.ps1 failed"); + + // Run split-test-matrix-by-deps.ps1 + var splitScriptPath = Path.Combine(FindRepoRoot(), "eng", "scripts", "split-test-matrix-by-deps.ps1"); + var githubOutputFile = Path.Combine(_tempDir.Path, "github_output.txt"); + File.WriteAllText(githubOutputFile, ""); + + using var splitCmd = new PowerShellCommand(splitScriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(2)) + .WithEnvironmentVariable("GITHUB_OUTPUT", githubOutputFile); + var splitResult = await splitCmd.ExecuteAsync( + "-AllTestsMatrixFile", $"\"{expandedFile}\"", + "-OutputToGitHubEnv"); + splitResult.EnsureSuccessful("split-test-matrix-by-deps.ps1 failed"); + + // Read split results from GITHUB_OUTPUT file + var splitOutputs = ParseGitHubOutputFile(githubOutputFile); + var noNugets = splitOutputs["tests_matrix_no_nugets"]; + var noNugetsOverflow = splitOutputs["tests_matrix_no_nugets_overflow"]; + var nugetsMatrix = splitOutputs["tests_matrix_requires_nugets"]; + var cliArchiveMatrix = splitOutputs["tests_matrix_requires_cli_archive"]; + + var allNoNugets = noNugets.Include.Concat(noNugetsOverflow.Include).ToArray(); + + // Regular project: 1 project × 3 OSes = 3 + var regularEntries = allNoNugets.Where(e => e.ProjectName == "RegularProject").ToArray(); + Assert.Equal(3, regularEntries.Length); + + // Split project: 2 classes × 3 OSes = 6 + var splitEntries = allNoNugets.Where(e => e.ProjectName == "SplitMultiOS").ToArray(); + Assert.Equal(6, splitEntries.Length); + Assert.Equal(2, splitEntries.Count(e => e.RunsOn == "ubuntu-latest")); + Assert.Equal(2, splitEntries.Count(e => e.RunsOn == "windows-latest")); + Assert.Equal(2, splitEntries.Count(e => e.RunsOn == "macos-latest")); + + // Linux-only E2E: 1 project × 1 OS = 1, in requires-nugets matrix + var e2eEntries = nugetsMatrix.Include.Where(e => e.ProjectName == "LinuxE2E").ToArray(); + Assert.Single(e2eEntries); + Assert.Equal("ubuntu-latest", e2eEntries[0].RunsOn); + Assert.True(e2eEntries[0].RequiresNugets); + + // CLI E2E: 1 project × 1 OS = 1, in requires-cli-archive matrix + var cliE2eEntries = cliArchiveMatrix.Include.Where(e => e.ProjectName == "CliE2E").ToArray(); + Assert.Single(cliE2eEntries); + Assert.Equal("ubuntu-latest", cliE2eEntries[0].RunsOn); + Assert.True(cliE2eEntries[0].RequiresCliArchive); + + // Total no-nugets: 3 + 6 = 9, Total nugets: 1, Total cli-archive: 1 + Assert.Equal(9, allNoNugets.Length); + Assert.Single(nugetsMatrix.Include); + Assert.Single(cliArchiveMatrix.Include); + } + + private async Task RunScript( + string canonicalMatrixFile, + string? outputMatrixFile = null) + { + using var cmd = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(2)); + + var args = new List + { + "-CanonicalMatrixFile", $"\"{canonicalMatrixFile}\"" + }; + + if (!string.IsNullOrEmpty(outputMatrixFile)) + { + args.Add("-OutputMatrixFile"); + args.Add($"\"{outputMatrixFile}\""); + } + + return await cmd.ExecuteAsync(args.ToArray()); + } + + private static GitHubActionsMatrix ParseGitHubMatrix(string path) + { + var json = File.ReadAllText(path); + return ParseGitHubMatrixJson(json); + } + + private static Dictionary ParseGitHubOutputFile(string path) + { + var results = new Dictionary(); + foreach (var line in File.ReadAllLines(path)) + { + var eqIndex = line.IndexOf('='); + if (eqIndex < 0) + { + continue; + } + + var key = line[..eqIndex]; + var value = line[(eqIndex + 1)..]; + results[key] = ParseGitHubMatrixJson(value); + } + return results; + } + + private static GitHubActionsMatrix ParseGitHubMatrixJson(string json) + { + var document = JsonDocument.Parse(json); + var include = document.RootElement.GetProperty("include"); + + // PowerShell's ConvertTo-Json serializes single-element arrays as objects + // Handle both cases + ExpandedMatrixEntry[] entries; + if (include.ValueKind == JsonValueKind.Array) + { + entries = JsonSerializer.Deserialize(include.GetRawText(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? []; + } + else if (include.ValueKind == JsonValueKind.Object) + { + var singleEntry = JsonSerializer.Deserialize(include.GetRawText(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + entries = singleEntry is not null ? [singleEntry] : []; + } + else + { + entries = []; + } + + return new GitHubActionsMatrix { Include = entries }; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find repository root"); + } +} diff --git a/tests/Infrastructure.Tests/PowerShellScripts/PowerShellCommand.cs b/tests/Infrastructure.Tests/PowerShellScripts/PowerShellCommand.cs new file mode 100644 index 00000000000..74db6d3eb74 --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/PowerShellCommand.cs @@ -0,0 +1,330 @@ +// 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; +using System.Runtime.InteropServices; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Executes PowerShell scripts using pwsh. +/// Based on the ToolCommand pattern from tests/Shared/TemplatesTesting/ToolCommand.cs. +/// +public sealed class PowerShellCommand : IDisposable +{ + private readonly string _scriptPath; + private readonly ITestOutputHelper _testOutput; + private readonly string _label; + private TimeSpan? _timeout; + private readonly string _msgPrefix; + + public Process? CurrentProcess { get; private set; } + public Dictionary Environment { get; } = new(); + public string? WorkingDirectory { get; set; } + + public event DataReceivedEventHandler? ErrorDataReceived; + public event DataReceivedEventHandler? OutputDataReceived; + + public PowerShellCommand(string scriptPath, ITestOutputHelper testOutput, string label = "") + { + _scriptPath = scriptPath; + _testOutput = testOutput; + _label = label; + _msgPrefix = string.IsNullOrEmpty(_label) ? string.Empty : $"[{_label}] "; + } + + public PowerShellCommand WithWorkingDirectory(string dir) + { + WorkingDirectory = dir; + return this; + } + + public PowerShellCommand WithEnvironmentVariable(string key, string value) + { + Environment[key] = value; + return this; + } + + public PowerShellCommand WithTimeout(TimeSpan timeSpan) + { + _timeout = timeSpan; + return this; + } + + public async Task ExecuteAsync(params string[] args) + { + CancellationTokenSource cts = new(); + if (_timeout is not null) + { + cts.CancelAfter((int)_timeout.Value.TotalMilliseconds); + } + + try + { + return await ExecuteAsyncInternal(cts.Token, args); + } + catch (TaskCanceledException tce) when (cts.IsCancellationRequested) + { + throw new TaskCanceledException( + $"Command execution timed out after {_timeout!.Value.TotalSeconds} secs: pwsh {_scriptPath}", + tce); + } + } + + public void Dispose() + { + CurrentProcess?.CloseAndKillProcessIfRunning(); + } + + private async Task ExecuteAsyncInternal(CancellationToken token, string[] args) + { + Stopwatch runTimeStopwatch = new(); + var fullArgs = BuildArguments(args); + + _testOutput.WriteLine($"{_msgPrefix}Executing - pwsh {fullArgs} {WorkingDirectoryInfo()}"); + + object outputLock = new(); + var outputLines = new List(); + + CurrentProcess = CreateProcess(fullArgs); + + CurrentProcess.ErrorDataReceived += (s, e) => + { + if (e.Data is null) + { + return; + } + + lock (outputLock) + { + outputLines.Add(e.Data); + } + _testOutput.WriteLine($"{_msgPrefix}{e.Data}"); + ErrorDataReceived?.Invoke(s, e); + }; + + CurrentProcess.OutputDataReceived += (s, e) => + { + if (e.Data is null) + { + return; + } + + lock (outputLock) + { + outputLines.Add(e.Data); + } + _testOutput.WriteLine($"{_msgPrefix}{e.Data}"); + OutputDataReceived?.Invoke(s, e); + }; + + try + { + runTimeStopwatch.Start(); + + TaskCompletionSource exitedTcs = new(); + CurrentProcess.EnableRaisingEvents = true; + CurrentProcess.Exited += (s, a) => + { + exitedTcs.SetResult(); + runTimeStopwatch.Stop(); + }; + + CurrentProcess.Start(); + CurrentProcess.BeginOutputReadLine(); + CurrentProcess.BeginErrorReadLine(); + + await exitedTcs.Task.WaitAsync(token).ConfigureAwait(false); + + _testOutput.WriteLine($"{_msgPrefix}Got the Exited event, waiting on WaitForExitAsync"); + var waitForExitTask = CurrentProcess.WaitForExitAsync(token); + var completedTask = await Task.WhenAny(waitForExitTask, Task.Delay(TimeSpan.FromSeconds(5), token)).ConfigureAwait(false); + if (completedTask != waitForExitTask) + { + _testOutput.WriteLine($"{_msgPrefix}Timed out waiting for it. Ignoring."); + } + + _testOutput.WriteLine($"{_msgPrefix}Process ran for {runTimeStopwatch.Elapsed.TotalSeconds:F2} secs"); + + return new CommandResult( + CurrentProcess.StartInfo, + CurrentProcess.ExitCode, + GetFullOutput()); + } + catch (Exception ex) + { + _testOutput.WriteLine($"Exception: {ex}"); + _testOutput.WriteLine($"output: {GetFullOutput()}"); + throw; + } + finally + { + if (!CurrentProcess.TryGetHasExited()) + { + _testOutput.WriteLine($"{_msgPrefix}Process has been running for {runTimeStopwatch.Elapsed.TotalSeconds:F2} secs"); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + CurrentProcess.CloseMainWindow(); + } + + _testOutput.WriteLine($"Killing"); + CurrentProcess.Kill(entireProcessTree: true); + } + CurrentProcess.Dispose(); + } + + string GetFullOutput() + { + lock (outputLock) + { + return string.Join(System.Environment.NewLine, outputLines); + } + } + } + + private string BuildArguments(string[] args) + { + var argsList = new List + { + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-File", $"\"{_scriptPath}\"" + }; + argsList.AddRange(args); + return string.Join(" ", argsList); + } + + private Process CreateProcess(string args) + { + var psi = new ProcessStartInfo + { + FileName = "pwsh", + Arguments = args, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = true, + UseShellExecute = false + }; + + AddEnvironmentVariablesTo(psi); + AddWorkingDirectoryTo(psi); + + return new Process + { + StartInfo = psi, + EnableRaisingEvents = true + }; + } + + private string WorkingDirectoryInfo() + { + if (WorkingDirectory is null) + { + return ""; + } + return $" in pwd {WorkingDirectory}"; + } + + private void AddEnvironmentVariablesTo(ProcessStartInfo psi) + { + foreach (var item in Environment) + { + _testOutput.WriteLine($"{_msgPrefix}\t[{item.Key}] = {item.Value}"); + psi.Environment[item.Key] = item.Value; + } + } + + private void AddWorkingDirectoryTo(ProcessStartInfo psi) + { + if (!string.IsNullOrWhiteSpace(WorkingDirectory)) + { + if (!Directory.Exists(WorkingDirectory)) + { + throw new DirectoryNotFoundException($"Working directory '{WorkingDirectory}' does not exist."); + } + psi.WorkingDirectory = WorkingDirectory; + } + } +} + +/// +/// Result of a command execution. +/// +public readonly struct CommandResult +{ + public ProcessStartInfo StartInfo { get; } + public int ExitCode { get; } + public string Output { get; } + + public CommandResult(ProcessStartInfo startInfo, int exitCode, string output) + { + StartInfo = startInfo; + ExitCode = exitCode; + Output = output; + } + + public CommandResult EnsureSuccessful(string messagePrefix = "") + => EnsureExitCode(0, messagePrefix); + + public CommandResult EnsureExitCode(int expectedExitCode, string messagePrefix = "") + { + if (ExitCode != expectedExitCode) + { + var message = $"{messagePrefix} Expected {expectedExitCode} exit code but got {ExitCode}: {StartInfo.FileName} {StartInfo.Arguments}"; + if (!string.IsNullOrEmpty(Output)) + { + message += $"{System.Environment.NewLine}Output:{System.Environment.NewLine}{Output}"; + } + throw new CommandException(message, this); + } + return this; + } +} + +/// +/// Exception thrown when a command fails. +/// +public class CommandException : Exception +{ + public CommandResult Result { get; } + + public CommandException(string message, CommandResult result) : base(message) + { + Result = result; + } +} + +/// +/// Extension methods for Process handling. +/// +internal static class ProcessExtensions +{ + public static bool TryGetHasExited(this Process process) + { + try + { + return process.HasExited; + } + catch (InvalidOperationException ie) when (ie.Message.Contains("No process is associated with this object")) + { + return true; + } + } + + public static void CloseAndKillProcessIfRunning(this Process? process) + { + if (process is null || process.TryGetHasExited()) + { + return; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + process.CloseMainWindow(); + } + process.Kill(entireProcessTree: true); + process.Dispose(); + } +} diff --git a/tests/Infrastructure.Tests/PowerShellScripts/SplitTestMatrixByDepsTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/SplitTestMatrixByDepsTests.cs new file mode 100644 index 00000000000..5e50878c17d --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/SplitTestMatrixByDepsTests.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.Text.Json; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for eng/scripts/split-test-matrix-by-deps.ps1 +/// +public class SplitTestMatrixByDepsTests : IDisposable +{ + private readonly TestTempDirectory _tempDir = new(); + private readonly string _scriptPath; + private readonly string _githubOutputFile; + private readonly ITestOutputHelper _output; + + public SplitTestMatrixByDepsTests(ITestOutputHelper output) + { + _output = output; + _scriptPath = Path.Combine(FindRepoRoot(), "eng", "scripts", "split-test-matrix-by-deps.ps1"); + _githubOutputFile = Path.Combine(_tempDir.Path, "github_output.txt"); + File.WriteAllText(_githubOutputFile, ""); + } + + public void Dispose() => _tempDir.Dispose(); + + [Fact] + [RequiresTools(["pwsh"])] + public async Task SplitsNoDepsTestsIntoNoNugetsBucket() + { + var matrixJson = BuildMatrixJson( + new { name = "A", shortname = "a", runs_on = "ubuntu-latest" }, + new { name = "B", shortname = "b", runs_on = "ubuntu-latest" }); + + var result = await RunScript(allTestsMatrix: matrixJson); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.Equal(2, outputs["tests_matrix_no_nugets"].Include.Length); + Assert.Empty(outputs["tests_matrix_requires_nugets"].Include); + Assert.Empty(outputs["tests_matrix_requires_cli_archive"].Include); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task SplitsRequiresNugetsTestsIntoRequiresNugetsBucket() + { + var matrixJson = BuildMatrixJson( + new { name = "Plain", shortname = "p", runs_on = "ubuntu-latest" }, + new { name = "NugetTest", shortname = "nt", runs_on = "ubuntu-latest", requiresNugets = true }); + + var result = await RunScript(allTestsMatrix: matrixJson); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.Single(outputs["tests_matrix_no_nugets"].Include); + Assert.Equal("Plain", outputs["tests_matrix_no_nugets"].Include[0].Name); + Assert.Single(outputs["tests_matrix_requires_nugets"].Include); + Assert.Equal("NugetTest", outputs["tests_matrix_requires_nugets"].Include[0].Name); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task SplitsRequiresCliArchiveTestsIntoCliArchiveBucket() + { + var matrixJson = BuildMatrixJson( + new { name = "Plain", shortname = "p", runs_on = "ubuntu-latest" }, + new { name = "CliTest", shortname = "ct", runs_on = "ubuntu-latest", requiresCliArchive = true }); + + var result = await RunScript(allTestsMatrix: matrixJson); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.Single(outputs["tests_matrix_no_nugets"].Include); + Assert.Single(outputs["tests_matrix_requires_cli_archive"].Include); + Assert.Equal("CliTest", outputs["tests_matrix_requires_cli_archive"].Include[0].Name); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task CliArchiveTakesPriorityOverNugets() + { + var matrixJson = BuildMatrixJson( + new { name = "Both", shortname = "b", runs_on = "ubuntu-latest", requiresNugets = true, requiresCliArchive = true }); + + var result = await RunScript(allTestsMatrix: matrixJson); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.Single(outputs["tests_matrix_requires_cli_archive"].Include); + Assert.Empty(outputs["tests_matrix_requires_nugets"].Include); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task OverflowsEntriesBeyondThreshold() + { + var entries = Enumerable.Range(1, 8).Select(i => + (object)new { name = $"T{i}", shortname = $"t{i}", runs_on = "ubuntu-latest" }).ToArray(); + + var matrixJson = BuildMatrixJson(entries); + + var result = await RunScript(allTestsMatrix: matrixJson, overflowThreshold: 5); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.Equal(5, outputs["tests_matrix_no_nugets"].Include.Length); + Assert.Equal(3, outputs["tests_matrix_no_nugets_overflow"].Include.Length); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task NoOverflowWhenBelowThreshold() + { + var entries = Enumerable.Range(1, 5).Select(i => + (object)new { name = $"T{i}", shortname = $"t{i}", runs_on = "ubuntu-latest" }).ToArray(); + + var matrixJson = BuildMatrixJson(entries); + + var result = await RunScript(allTestsMatrix: matrixJson, overflowThreshold: 10); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.Equal(5, outputs["tests_matrix_no_nugets"].Include.Length); + Assert.Empty(outputs["tests_matrix_no_nugets_overflow"].Include); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task HandlesEmptyMatrix() + { + var matrixJson = BuildMatrixJson(); + + var result = await RunScript(allTestsMatrix: matrixJson); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.Empty(outputs["tests_matrix_no_nugets"].Include); + Assert.Empty(outputs["tests_matrix_no_nugets_overflow"].Include); + Assert.Empty(outputs["tests_matrix_requires_nugets"].Include); + Assert.Empty(outputs["tests_matrix_requires_cli_archive"].Include); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task AllFourOutputKeysAlwaysPresent() + { + var matrixJson = BuildMatrixJson( + new { name = "T", shortname = "t", runs_on = "ubuntu-latest" }); + + var result = await RunScript(allTestsMatrix: matrixJson); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.True(outputs.ContainsKey("tests_matrix_no_nugets")); + Assert.True(outputs.ContainsKey("tests_matrix_no_nugets_overflow")); + Assert.True(outputs.ContainsKey("tests_matrix_requires_nugets")); + Assert.True(outputs.ContainsKey("tests_matrix_requires_cli_archive")); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task ReadsMatrixFromFile() + { + var matrixJson = BuildMatrixJson( + new { name = "FromFile", shortname = "ff", runs_on = "ubuntu-latest" }); + + var matrixFile = Path.Combine(_tempDir.Path, "matrix.json"); + File.WriteAllText(matrixFile, matrixJson); + + var result = await RunScript(allTestsMatrixFile: matrixFile); + + result.EnsureSuccessful(); + + var outputs = ParseGitHubOutputFile(); + Assert.Single(outputs["tests_matrix_no_nugets"].Include); + Assert.Equal("FromFile", outputs["tests_matrix_no_nugets"].Include[0].Name); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenNoInputProvided() + { + using var cmd = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(2)) + .WithEnvironmentVariable("GITHUB_OUTPUT", _githubOutputFile); + + var result = await cmd.ExecuteAsync("-OutputToGitHubEnv"); + + Assert.NotEqual(0, result.ExitCode); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenMatrixFileNotFound() + { + var nonExistentFile = Path.Combine(_tempDir.Path, "nonexistent.json"); + + using var cmd = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(2)) + .WithEnvironmentVariable("GITHUB_OUTPUT", _githubOutputFile); + + var result = await cmd.ExecuteAsync( + "-AllTestsMatrixFile", $"\"{nonExistentFile}\"", + "-OutputToGitHubEnv"); + + Assert.NotEqual(0, result.ExitCode); + } + + private async Task RunScript( + string? allTestsMatrix = null, + string? allTestsMatrixFile = null, + int? overflowThreshold = null) + { + using var cmd = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(2)) + .WithEnvironmentVariable("GITHUB_OUTPUT", _githubOutputFile); + + var args = new List(); + + if (!string.IsNullOrEmpty(allTestsMatrix)) + { + // Write JSON to a temp file to avoid command-line quoting issues + var tempMatrixFile = Path.Combine(_tempDir.Path, $"matrix_input_{Guid.NewGuid():N}.json"); + File.WriteAllText(tempMatrixFile, allTestsMatrix); + args.Add("-AllTestsMatrixFile"); + args.Add($"\"{tempMatrixFile}\""); + } + + if (!string.IsNullOrEmpty(allTestsMatrixFile)) + { + args.Add("-AllTestsMatrixFile"); + args.Add($"\"{allTestsMatrixFile}\""); + } + + if (overflowThreshold.HasValue) + { + args.Add("-OverflowThreshold"); + args.Add(overflowThreshold.Value.ToString()); + } + + args.Add("-OutputToGitHubEnv"); + + return await cmd.ExecuteAsync(args.ToArray()); + } + + private static string BuildMatrixJson(params object[] entries) + { + var matrix = new { include = entries }; + return JsonSerializer.Serialize(matrix); + } + + private Dictionary ParseGitHubOutputFile() + { + var results = new Dictionary(); + foreach (var line in File.ReadAllLines(_githubOutputFile)) + { + var eqIndex = line.IndexOf('='); + if (eqIndex < 0) + { + continue; + } + + var key = line[..eqIndex]; + var value = line[(eqIndex + 1)..]; + results[key] = ParseGitHubMatrixJson(value); + } + return results; + } + + private static GitHubActionsMatrix ParseGitHubMatrixJson(string json) + { + var document = JsonDocument.Parse(json); + var include = document.RootElement.GetProperty("include"); + + ExpandedMatrixEntry[] entries; + if (include.ValueKind == JsonValueKind.Array) + { + entries = JsonSerializer.Deserialize(include.GetRawText(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? []; + } + else if (include.ValueKind == JsonValueKind.Object) + { + var singleEntry = JsonSerializer.Deserialize(include.GetRawText(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + entries = singleEntry is not null ? [singleEntry] : []; + } + else + { + entries = []; + } + + return new GitHubActionsMatrix { Include = entries }; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find repository root"); + } +} diff --git a/tests/Infrastructure.Tests/PowerShellScripts/SplitTestProjectsTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/SplitTestProjectsTests.cs new file mode 100644 index 00000000000..1479b882e75 --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/SplitTestProjectsTests.cs @@ -0,0 +1,228 @@ +// 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; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for eng/scripts/split-test-projects-for-ci.ps1 +/// +/// Note: These tests are more limited because the script requires: +/// 1. A built test assembly with partition attributes, OR +/// 2. An executable test assembly to run --list-tests +/// +/// We test the partition extraction path using mock assemblies, but we can't easily +/// test the class-based fallback without a real test project. +/// +public class SplitTestProjectsTests : IDisposable +{ + private readonly TestTempDirectory _tempDir = new(); + private readonly string _scriptPath; + private readonly string _repoRoot; + private readonly ITestOutputHelper _output; + + public SplitTestProjectsTests(ITestOutputHelper output) + { + _output = output; + _repoRoot = FindRepoRoot(); + _scriptPath = Path.Combine(_repoRoot, "eng", "scripts", "split-test-projects-for-ci.ps1"); + } + + public void Dispose() => _tempDir.Dispose(); + + [Fact] + [RequiresTools(["pwsh"])] + public async Task UsesCollectionModeWithPartitions() + { + // Arrange - Create a mock assembly with partition attributes + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestClass1", "PartitionA"), + ("TestClass2", "PartitionB")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.json"); + + // Act (the script builds ExtractTestPartitions tool internally if needed) + var result = await RunScript( + assemblyPath, + runCommand: "echo", // Won't be used since we have partitions + testClassPrefix: "TestNamespace", + outputFile: outputFile); + + // Assert + result.EnsureSuccessful("split-test-projects-for-ci.ps1 failed"); + Assert.Contains("Mode: collection", result.Output); + + var partitions = ParsePartitionsJson(outputFile); + Assert.Contains("collection:PartitionA", partitions.TestPartitions); + Assert.Contains("collection:PartitionB", partitions.TestPartitions); + Assert.Contains("uncollected:*", partitions.TestPartitions); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task IncludesUncollectedEntry() + { + // Arrange + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestClass1", "OnlyPartition")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.json"); + + // Act + var result = await RunScript( + assemblyPath, + runCommand: "echo", + testClassPrefix: "TestNamespace", + outputFile: outputFile); + + // Assert + result.EnsureSuccessful(); + + var partitions = ParsePartitionsJson(outputFile); + // Should always have uncollected:* at the end in collection mode + Assert.Contains("uncollected:*", partitions.TestPartitions); + Assert.Equal("uncollected:*", partitions.TestPartitions.Last()); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task OutputsValidJson() + { + // Arrange + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestClass1", "Part1"), + ("TestClass2", "Part2")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.json"); + + // Act + var result = await RunScript( + assemblyPath, + runCommand: "echo", + testClassPrefix: "TestNamespace", + outputFile: outputFile); + + // Assert + result.EnsureSuccessful(); + Assert.True(File.Exists(outputFile), "Output file should exist"); + + // Verify it's valid JSON + var json = File.ReadAllText(outputFile); + var document = JsonDocument.Parse(json); // Throws if invalid + Assert.True(document.RootElement.TryGetProperty("testPartitions", out var partitions)); + Assert.Equal(JsonValueKind.Array, partitions.ValueKind); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task SortsPartitionsAlphabetically() + { + // Arrange + var assemblyPath = Path.Combine(_tempDir.Path, "TestAssembly.dll"); + MockAssemblyBuilder.CreateAssemblyWithPartitions( + assemblyPath, + ("TestZ", "Zebra"), + ("TestA", "Apple"), + ("TestM", "Mango")); + + var outputFile = Path.Combine(_tempDir.Path, "partitions.json"); + + // Act + var result = await RunScript( + assemblyPath, + runCommand: "echo", + testClassPrefix: "TestNamespace", + outputFile: outputFile); + + // Assert + result.EnsureSuccessful(); + + var partitions = ParsePartitionsJson(outputFile); + // Remove uncollected:* for sorting check + var collectionEntries = partitions.TestPartitions + .Where(p => p.StartsWith("collection:")) + .ToArray(); + + Assert.Equal("collection:Apple", collectionEntries[0]); + Assert.Equal("collection:Mango", collectionEntries[1]); + Assert.Equal("collection:Zebra", collectionEntries[2]); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenAssemblyNotFound() + { + // Arrange + var nonExistentPath = Path.Combine(_tempDir.Path, "DoesNotExist.dll"); + var outputFile = Path.Combine(_tempDir.Path, "partitions.json"); + + // Act + var result = await RunScript( + nonExistentPath, + runCommand: "echo", + testClassPrefix: "TestNamespace", + outputFile: outputFile); + + // Assert + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("not found", result.Output, StringComparison.OrdinalIgnoreCase); + } + + private async Task RunScript( + string assemblyPath, + string runCommand, + string testClassPrefix, + string outputFile) + { + using var cmd = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(3)); + + var args = new List + { + "-TestAssemblyPath", $"\"{assemblyPath}\"", + "-RunCommand", $"\"{runCommand}\"", + "-TestClassNamePrefixForCI", $"\"{testClassPrefix}\"", + "-TestPartitionsJsonFile", $"\"{outputFile}\"", + "-RepoRoot", $"\"{_repoRoot}\"" + }; + + return await cmd.ExecuteAsync(args.ToArray()); + } + + private static TestPartitionsJson ParsePartitionsJson(string path) + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new InvalidOperationException("Failed to parse partitions JSON"); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find repository root"); + } + + private sealed class TestPartitionsJson + { + public string[] TestPartitions { get; set; } = []; + } +} diff --git a/tests/Infrastructure.Tests/Shared/TestDataBuilder.cs b/tests/Infrastructure.Tests/Shared/TestDataBuilder.cs new file mode 100644 index 00000000000..ad9f4390d94 --- /dev/null +++ b/tests/Infrastructure.Tests/Shared/TestDataBuilder.cs @@ -0,0 +1,336 @@ +// 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; +using System.Text.Json.Serialization; + +namespace Infrastructure.Tests; + +/// +/// Helper to create test input JSON files for PowerShell scripts. +/// +public static class TestDataBuilder +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Creates a .tests-metadata.json file for a regular (non-split) test project. + /// + public static string CreateTestsMetadataJson( + string outputPath, + string projectName, + string testProjectPath, + string? shortName = null, + string? testSessionTimeout = null, + string? testHangTimeout = null, + bool requiresNugets = false, + bool requiresTestSdk = false, + bool requiresCliArchive = false, + string? extraTestArgs = null, + string[]? supportedOSes = null) + { + var metadata = new TestMetadata + { + ProjectName = projectName, + TestProjectPath = testProjectPath, + ShortName = shortName ?? projectName, + SplitTests = "false", + TestSessionTimeout = testSessionTimeout, + TestHangTimeout = testHangTimeout, + RequiresNugets = requiresNugets ? "true" : null, + RequiresTestSdk = requiresTestSdk ? "true" : null, + RequiresCliArchive = requiresCliArchive ? "true" : null, + ExtraTestArgs = extraTestArgs, + SupportedOSes = supportedOSes ?? ["windows", "linux", "macos"] + }; + + var json = JsonSerializer.Serialize(metadata, s_jsonOptions); + var outputDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + File.WriteAllText(outputPath, json); + return outputPath; + } + + /// + /// Creates a .tests-metadata.json file for a split test project. + /// + public static string CreateSplitTestsMetadataJson( + string outputPath, + string projectName, + string testProjectPath, + string? shortName = null, + string? testSessionTimeout = null, + string? testHangTimeout = null, + string? uncollectedTestsSessionTimeout = null, + string? uncollectedTestsHangTimeout = null, + bool requiresNugets = false, + bool requiresTestSdk = false, + string[]? supportedOSes = null) + { + var metadata = new TestMetadata + { + ProjectName = projectName, + TestProjectPath = testProjectPath, + ShortName = shortName ?? projectName, + SplitTests = "true", + TestSessionTimeout = testSessionTimeout, + TestHangTimeout = testHangTimeout, + UncollectedTestsSessionTimeout = uncollectedTestsSessionTimeout, + UncollectedTestsHangTimeout = uncollectedTestsHangTimeout, + RequiresNugets = requiresNugets ? "true" : null, + RequiresTestSdk = requiresTestSdk ? "true" : null, + SupportedOSes = supportedOSes ?? ["windows", "linux", "macos"] + }; + + var json = JsonSerializer.Serialize(metadata, s_jsonOptions); + var outputDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + File.WriteAllText(outputPath, json); + return outputPath; + } + + /// + /// Creates a .tests-partitions.json file with collection-based partitions. + /// + public static string CreateTestsPartitionsJson( + string outputPath, + params string[] partitionNames) + { + var partitions = new List(); + foreach (var name in partitionNames) + { + partitions.Add($"collection:{name}"); + } + partitions.Add("uncollected:*"); + + var data = new TestPartitionsJson { TestPartitions = partitions.ToArray() }; + var json = JsonSerializer.Serialize(data, s_jsonOptions); + var outputDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + File.WriteAllText(outputPath, json); + return outputPath; + } + + /// + /// Creates a .tests-partitions.json file with class-based entries. + /// + public static string CreateClassBasedPartitionsJson( + string outputPath, + params string[] classNames) + { + var partitions = classNames.Select(c => $"class:{c}").ToArray(); + var data = new TestPartitionsJson { TestPartitions = partitions }; + var json = JsonSerializer.Serialize(data, s_jsonOptions); + var outputDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + File.WriteAllText(outputPath, json); + return outputPath; + } + + /// + /// Creates a canonical test matrix JSON file (output of build-test-matrix.ps1). + /// + public static string CreateCanonicalMatrixJson( + string outputPath, + CanonicalMatrixEntry[]? tests = null) + { + var matrix = new CanonicalMatrix + { + Tests = tests ?? [] + }; + + var json = JsonSerializer.Serialize(matrix, s_jsonOptions); + var outputDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + File.WriteAllText(outputPath, json); + return outputPath; + } + + /// + /// Creates a single canonical matrix entry. + /// + public static CanonicalMatrixEntry CreateMatrixEntry( + string name, + string projectName, + string testProjectPath, + string type = "regular", + string? shortname = null, + string? workitemprefix = null, + string? collection = null, + string? classname = null, + string? extraTestArgs = null, + string testSessionTimeout = "20m", + string testHangTimeout = "10m", + bool requiresNugets = false, + bool requiresTestSdk = false, + bool requiresCliArchive = false, + string[]? supportedOSes = null) + { + return new CanonicalMatrixEntry + { + Type = type, + Name = name, + ProjectName = projectName, + TestProjectPath = testProjectPath, + Shortname = shortname ?? name, + Workitemprefix = workitemprefix ?? projectName, + Collection = collection, + Classname = classname, + ExtraTestArgs = extraTestArgs ?? "", + TestSessionTimeout = testSessionTimeout, + TestHangTimeout = testHangTimeout, + RequiresNugets = requiresNugets, + RequiresTestSdk = requiresTestSdk, + RequiresCliArchive = requiresCliArchive, + SupportedOSes = supportedOSes ?? ["windows", "linux", "macos"] + }; + } + + private sealed class TestMetadata + { + [JsonPropertyName("projectName")] + public string ProjectName { get; set; } = ""; + + [JsonPropertyName("testProjectPath")] + public string TestProjectPath { get; set; } = ""; + + [JsonPropertyName("shortName")] + public string ShortName { get; set; } = ""; + + [JsonPropertyName("splitTests")] + public string SplitTests { get; set; } = "false"; + + [JsonPropertyName("testSessionTimeout")] + public string? TestSessionTimeout { get; set; } + + [JsonPropertyName("testHangTimeout")] + public string? TestHangTimeout { get; set; } + + [JsonPropertyName("uncollectedTestsSessionTimeout")] + public string? UncollectedTestsSessionTimeout { get; set; } + + [JsonPropertyName("uncollectedTestsHangTimeout")] + public string? UncollectedTestsHangTimeout { get; set; } + + [JsonPropertyName("requiresNugets")] + public string? RequiresNugets { get; set; } + + [JsonPropertyName("requiresTestSdk")] + public string? RequiresTestSdk { get; set; } + + [JsonPropertyName("requiresCliArchive")] + public string? RequiresCliArchive { get; set; } + + [JsonPropertyName("extraTestArgs")] + public string? ExtraTestArgs { get; set; } + + [JsonPropertyName("supportedOSes")] + public string[] SupportedOSes { get; set; } = ["windows", "linux", "macos"]; + } + + private sealed class TestPartitionsJson + { + [JsonPropertyName("testPartitions")] + public string[] TestPartitions { get; set; } = []; + } +} + +/// +/// Represents the canonical test matrix output. +/// +public class CanonicalMatrix +{ + [JsonPropertyName("tests")] + public CanonicalMatrixEntry[] Tests { get; set; } = []; +} + +/// +/// Represents a single entry in the canonical test matrix. +/// +public class CanonicalMatrixEntry +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "regular"; + + [JsonPropertyName("projectName")] + public string ProjectName { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("shortname")] + public string Shortname { get; set; } = ""; + + [JsonPropertyName("testProjectPath")] + public string TestProjectPath { get; set; } = ""; + + [JsonPropertyName("workitemprefix")] + public string Workitemprefix { get; set; } = ""; + + [JsonPropertyName("collection")] + public string? Collection { get; set; } + + [JsonPropertyName("classname")] + public string? Classname { get; set; } + + [JsonPropertyName("extraTestArgs")] + public string ExtraTestArgs { get; set; } = ""; + + [JsonPropertyName("testSessionTimeout")] + public string TestSessionTimeout { get; set; } = "20m"; + + [JsonPropertyName("testHangTimeout")] + public string TestHangTimeout { get; set; } = "10m"; + + [JsonPropertyName("requiresNugets")] + public bool RequiresNugets { get; set; } + + [JsonPropertyName("requiresTestSdk")] + public bool RequiresTestSdk { get; set; } + + [JsonPropertyName("requiresCliArchive")] + public bool RequiresCliArchive { get; set; } + + [JsonPropertyName("splitTests")] + public bool SplitTests { get; set; } + + [JsonPropertyName("supportedOSes")] + public string[] SupportedOSes { get; set; } = ["windows", "linux", "macos"]; +} + +/// +/// Represents an expanded GitHub Actions matrix entry. +/// +public class ExpandedMatrixEntry : CanonicalMatrixEntry +{ + [JsonPropertyName("runs-on")] + public string RunsOn { get; set; } = ""; +} + +/// +/// Represents the GitHub Actions matrix format. +/// +public class GitHubActionsMatrix +{ + [JsonPropertyName("include")] + public ExpandedMatrixEntry[] Include { get; set; } = []; +} diff --git a/tests/Shared/GetTestProjects.proj b/tests/Shared/GetTestProjects.proj deleted file mode 100644 index 6318b91b370..00000000000 --- a/tests/Shared/GetTestProjects.proj +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - $(MSBuildThisFileDirectory)..\..\ - - - - <_TestProjectsToExclude Include="$(RepoRoot)tests\Shared\**\*Tests.csproj" /> - <_TestProjectsToExclude Include="$(RepoRoot)tests\testproject\**\*Tests.csproj" /> - <_TestProjectsToExclude Include="$(RepoRoot)tests\TestingAppHost1\**\*Tests.csproj" /> - - - <_TestProjectsToExclude Include="$(RepoRoot)tests\Aspire.EndToEnd.Tests\**\*Tests.csproj" /> - - <_TestProjectsToExclude Include="$(RepoRoot)tests\Aspire.Templates.Tests\**\*Tests.csproj" /> - - <_TestProjectsToExclude Include="$(RepoRoot)tests\Aspire.Cli.EndToEnd.Tests\**\*.csproj" /> - - <_TestProjects Include="$(RepoRoot)tests\**\*Tests.csproj" - Exclude="@(_TestProjectsToExclude)" /> - - - - - - - - - - - - - - - - - - diff --git a/tests/helix/send-to-helix-templatestests.targets b/tests/helix/send-to-helix-templatestests.targets index 7a57735fb97..7f5a55cb605 100644 --- a/tests/helix/send-to-helix-templatestests.targets +++ b/tests/helix/send-to-helix-templatestests.targets @@ -31,9 +31,15 @@ <_TestRunCommand Condition="'$(HelixPerWorkItemPreCommand)' != ''">$(HelixPerWorkItemPreCommand) $(_ShellCommandSeparator) $(_TestRunCommand) - - - + + + + + + + <_TemplateTestsClassNames Include="@(_TemplateTestsClassNamesRaw->'%(Identity)'->Replace('class:', ''))" /> + diff --git a/tools/ExtractTestPartitions/ExtractTestPartitions.csproj b/tools/ExtractTestPartitions/ExtractTestPartitions.csproj new file mode 100644 index 00000000000..7cbfa621b34 --- /dev/null +++ b/tools/ExtractTestPartitions/ExtractTestPartitions.csproj @@ -0,0 +1,10 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + + + diff --git a/tools/ExtractTestPartitions/Program.cs b/tools/ExtractTestPartitions/Program.cs new file mode 100644 index 00000000000..7acd3c1d02c --- /dev/null +++ b/tools/ExtractTestPartitions/Program.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +if (args.Length < 4 || args[0] != "--assembly-path" || args[2] != "--output-file") +{ + Console.Error.WriteLine("Usage: ExtractTestPartitions --assembly-path --output-file "); + return 1; +} + +var assemblyPath = args[1]; +var outputFile = args[3]; + +ExtractPartitions(assemblyPath, outputFile); +return 0; + +static void ExtractPartitions(string assemblyPath, string outputFile) +{ + if (!File.Exists(assemblyPath)) + { + Console.Error.WriteLine($"Error: Assembly file not found: {assemblyPath}"); + Environment.Exit(1); + } + + var partitions = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + // Load the assembly using Assembly.LoadFrom + // We need to set up an assembly resolve handler for dependencies + var assemblyDirectory = Path.GetDirectoryName(assemblyPath); + if (string.IsNullOrEmpty(assemblyDirectory)) + { + Console.Error.WriteLine($"Error: Unable to determine directory for assembly: {assemblyPath}"); + Environment.Exit(1); + } + + AppDomain.CurrentDomain.AssemblyResolve += (sender, args) => + { + var assemblyName = new AssemblyName(args.Name); + var dllPath = Path.Combine(assemblyDirectory, assemblyName.Name + ".dll"); + if (File.Exists(dllPath)) + { + return Assembly.LoadFrom(dllPath); + } + return null; + }; + + var assembly = Assembly.LoadFrom(assemblyPath); + Console.WriteLine($"Loaded assembly: {assembly.FullName}"); + + // Iterate through all types in the assembly + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + // Some types couldn't be loaded due to missing dependencies + // Use the types that did load + types = ex.Types.Where(t => t is not null).Cast().ToArray(); + Console.WriteLine($"** Some types could not be loaded. Loaded {types.Length} types successfully."); + } + + foreach (var type in types) + { + // Only detect [Trait("Partition", "name")] attributes for splitting. + // xUnit [Collection] attributes are for shared fixtures, not CI splitting. + var attributes = type.GetCustomAttributesData(); + + foreach (var attr in attributes) + { + var attrTypeName = attr.AttributeType.FullName ?? attr.AttributeType.Name; + + // Check for Trait attribute with Partition key + if (!attrTypeName.EndsWith(".TraitAttribute") && attrTypeName != "TraitAttribute") + { + continue; + } + + if (attr.ConstructorArguments.Count < 2) + { + continue; + } + + var key = attr.ConstructorArguments[0].Value as string; + var value = attr.ConstructorArguments[1].Value as string; + + if (key?.Equals("Partition", StringComparison.OrdinalIgnoreCase) == true && + !string.IsNullOrWhiteSpace(value)) + { + partitions.Add(value); + Console.WriteLine($"Found Trait Partition: {value} on {type.Name}"); + } + } + } + + Console.WriteLine($"Total unique partitions found: {partitions.Count}"); + + // Write partitions to output file + var outputDir = Path.GetDirectoryName(outputFile); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + if (partitions.Count > 0) + { + File.WriteAllLines(outputFile, partitions.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + Console.WriteLine($"Partitions written to: {outputFile}"); + } + else + { + Console.WriteLine("No partitions found. Not creating output file."); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error extracting partitions: {ex.Message}"); + Console.Error.WriteLine($"Stack trace: {ex.StackTrace}"); + Environment.Exit(1); + } +} From ce61ece6b3b23d40891e1fee87516362430ecff3 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 3 Mar 2026 12:37:08 +0800 Subject: [PATCH 12/18] Consistent resource colors, resource colors match dashboard (#14832) --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + .../AppHostAuxiliaryBackchannel.cs | 7 +- src/Aspire.Cli/Commands/DescribeCommand.cs | 40 +++++----- src/Aspire.Cli/Commands/LogsCommand.cs | 4 + .../Commands/TelemetryCommandHelpers.cs | 22 +++++- .../Commands/TelemetryLogsCommand.cs | 6 +- .../Commands/TelemetrySpansCommand.cs | 6 +- .../Commands/TelemetryTracesCommand.cs | 6 ++ src/Aspire.Cli/Utils/ResourceColorMap.cs | 75 ++++++++++++------- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 1 + .../Components/_Imports.razor | 1 + .../Markdown/ResourceInlineRenderer.cs | 1 - .../ResourceGraph/ResourceGraphMapper.cs | 1 - .../ServiceClient/DashboardClient.cs | 9 +++ .../Otlp/Model => Shared}/ColorGenerator.cs | 40 +++++++--- .../Utils/ResourceColorMapTests.cs | 54 +++++++++++++ 16 files changed, 208 insertions(+), 66 deletions(-) rename src/{Aspire.Dashboard/Otlp/Model => Shared}/ColorGenerator.cs (63%) create mode 100644 tests/Aspire.Cli.Tests/Utils/ResourceColorMapTests.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index e6d7574c02c..70859bb694a 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -70,6 +70,7 @@ + diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index 02c0cab6a27..8d21d202ee8 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -293,9 +293,12 @@ public async Task> GetResourceSnapshotsAsync(Cancellation var snapshots = await rpc.InvokeWithCancellationAsync>( "GetResourceSnapshotsAsync", [], - cancellationToken).ConfigureAwait(false); + cancellationToken).ConfigureAwait(false) ?? []; + + // Sort resources by name for consistent ordering. + snapshots.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); - return snapshots ?? []; + return snapshots; } catch (RemoteMethodNotFoundException ex) { diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index a0f5469655d..45bed5dfcfe 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -133,27 +133,34 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.Success; } + var connection = result.Connection!; + + // Get dashboard URL and resource snapshots in parallel before + // dispatching to the snapshot or watch path. + var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken); + var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken); + + await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false); + + var dashboardBaseUrl = (await dashboardUrlsTask.ConfigureAwait(false))?.BaseUrlWithLoginToken; + var snapshots = await snapshotsTask.ConfigureAwait(false); + + // Pre-resolve colors for all resource names so that assignment is + // deterministic regardless of which resources are displayed. + _resourceColorMap.ResolveAll(snapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, snapshots))); + if (follow) { - return await ExecuteWatchAsync(result.Connection!, resourceName, format, cancellationToken); + return await ExecuteWatchAsync(connection, snapshots, dashboardBaseUrl, resourceName, format, cancellationToken); } else { - return await ExecuteSnapshotAsync(result.Connection!, resourceName, format, cancellationToken); + return ExecuteSnapshot(snapshots, dashboardBaseUrl, resourceName, format); } } - private async Task ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken) + private int ExecuteSnapshot(IReadOnlyList snapshots, string? dashboardBaseUrl, string? resourceName, OutputFormat format) { - // Get dashboard URL and resource snapshots in parallel - var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken); - var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken); - - await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false); - - var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false); - var snapshots = await snapshotsTask.ConfigureAwait(false); - // Filter by resource name if specified if (resourceName is not null) { @@ -167,8 +174,6 @@ private async Task ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connec return ExitCodeConstants.FailedToFindProject; } - // Use the dashboard base URL if available - var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken; var resourceList = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl); if (format == OutputFormat.Json) @@ -186,16 +191,11 @@ private async Task ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connec return ExitCodeConstants.Success; } - private async Task ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken) + private async Task ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, IReadOnlyList initialSnapshots, string? dashboardBaseUrl, string? resourceName, OutputFormat format, CancellationToken cancellationToken) { - // Get dashboard URL first for generating resource links - var dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); - var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken; - // Maintain a dictionary of the current state per resource for relationship resolution // and display name deduplication. Keyed by snapshot.Name so each resource has exactly // one entry representing its latest state. - var initialSnapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); var allResources = new Dictionary(StringComparers.ResourceName); foreach (var snapshot in initialSnapshots) { diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 9f741c8c65e..35810c74a07 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -164,6 +164,10 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Fetch snapshots for resource name resolution var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + // Pre-resolve colors for all resource names so that assignment is + // deterministic regardless of which resources are displayed. + _resourceColorMap.ResolveAll(snapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, snapshots))); + // Validate resource name exists (match by Name or DisplayName since users may pass either) if (resourceName is not null) { diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 03ff68822be..6b531cab100 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Otlp; using Aspire.Cli.Resources; +using Aspire.Cli.Utils; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; @@ -209,8 +210,16 @@ public static async Task GetAllResourcesAsync(HttpClient cli var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var resources = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray, cancellationToken).ConfigureAwait(false); - return resources!; + var resources = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray, cancellationToken).ConfigureAwait(false) ?? []; + + // Sort resources by name for consistent ordering. + Array.Sort(resources, (a, b) => + { + var cmp = string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); + return cmp != 0 ? cmp : string.Compare(a.InstanceId, b.InstanceId, StringComparison.OrdinalIgnoreCase); + }); + + return resources; } /// @@ -321,6 +330,15 @@ public static IReadOnlyList ToOtlpResources(ResourceInfoJson[] re return result; } + /// + /// Pre-resolves resource colors for all resources in sorted order so that + /// color assignment is deterministic regardless of encounter order in telemetry data. + /// + public static void ResolveResourceColors(ResourceColorMap colorMap, IReadOnlyList allResources) + { + colorMap.ResolveAll(allResources.Select(r => OtlpHelpers.GetResourceName(r, allResources))); + } + /// /// Resolves the display name for an OTLP resource using , /// appending a shortened instance ID when there are replicas with the same base name. diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index d488952a91e..d900474d272 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -118,6 +118,10 @@ private async Task FetchLogsAsync( // Resolve resource name to specific instances (handles replicas) var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); + var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); + + // Pre-resolve colors so assignment is deterministic regardless of data order + TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources); // If a resource was specified but not found, return error if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources)) @@ -126,8 +130,6 @@ private async Task FetchLogsAsync( return ExitCodeConstants.InvalidCommand; } - var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); - // Build query string with multiple resource parameters var additionalParams = new List<(string key, string? value)> { diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 6130da4c76f..8ff3a2ff3c3 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -114,6 +114,10 @@ private async Task FetchSpansAsync( // Resolve resource name to specific instances (handles replicas) var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); + var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); + + // Pre-resolve colors so assignment is deterministic regardless of data order + TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources); // If a resource was specified but not found, return error if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources)) @@ -122,8 +126,6 @@ private async Task FetchSpansAsync( return ExitCodeConstants.InvalidCommand; } - var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); - // Build query string with multiple resource parameters var additionalParams = new List<(string key, string? value)> { diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index c1808073429..cb6c5ca7bdb 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -115,6 +115,9 @@ private async Task FetchSingleTraceAsync( var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); + // Pre-resolve colors so assignment is deterministic regardless of data order + TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources); + var url = DashboardUrls.TelemetryTraceDetailApiUrl(baseUrl, traceId); _logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url); @@ -182,6 +185,9 @@ private async Task FetchTracesAsync( var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources); + // Pre-resolve colors so assignment is deterministic regardless of data order + TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources); + // Build query string with multiple resource parameters var additionalParams = new List<(string key, string? value)>(); if (hasError.HasValue) diff --git a/src/Aspire.Cli/Utils/ResourceColorMap.cs b/src/Aspire.Cli/Utils/ResourceColorMap.cs index 62269789123..72efe29d1b5 100644 --- a/src/Aspire.Cli/Utils/ResourceColorMap.cs +++ b/src/Aspire.Cli/Utils/ResourceColorMap.cs @@ -1,43 +1,66 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Spectre.Console; - namespace Aspire.Cli.Utils; /// /// Assigns a consistent color to each resource name for colorized console output. +/// Colors are derived from the Aspire Dashboard's dark theme accent palette to provide +/// a consistent visual experience across the CLI and dashboard. +/// Colors are returned as hex strings (e.g. #2CB7BD) suitable for use in +/// Spectre.Console markup. /// internal sealed class ResourceColorMap { - private static readonly Color[] s_resourceColors = - [ - Color.Cyan1, - Color.Green, - Color.Yellow, - Color.Blue, - Color.Magenta1, - Color.Orange1, - Color.DeepPink1, - Color.SpringGreen1, - Color.Aqua, - Color.Violet - ]; + /// + /// Dark theme hex colors keyed by accent variable name from . + /// The keys must match the variable names exactly so that the same palette index maps to the same + /// visual color in both the CLI and the dashboard. + /// + internal static readonly Dictionary s_hexColors = new() + { + ["--accent-teal"] = "#2CB7BD", + ["--accent-marigold"] = "#F3D58E", + ["--accent-brass"] = "#BF8B64", + ["--accent-peach"] = "#FFC18F", + ["--accent-coral"] = "#F89170", + ["--accent-royal-blue"] = "#88A1F0", + ["--accent-orchid"] = "#E19AD4", + ["--accent-brand-blue"] = "#1A7ECF", + ["--accent-seafoam"] = "#74D6C6", + ["--accent-mink"] = "#B9B2A4", + ["--accent-cyan"] = "#17A0A6", + ["--accent-gold"] = "#E3BA7A", + ["--accent-bronze"] = "#8E6038", + ["--accent-orange"] = "#FFA44A", + ["--accent-rust"] = "#EA6A3E", + ["--accent-navy"] = "#2A4C8A", + ["--accent-berry"] = "#D150C3", + ["--accent-ocean"] = "#16728F", + ["--accent-jade"] = "#51C0A5", + ["--accent-olive"] = "#847B63", + }; + + private readonly ColorGenerator _palette = new(); - private readonly Dictionary _colorMap = new(StringComparers.ResourceName); - private int _nextColorIndex; + /// + /// Gets the hex color string assigned to the specified resource name, assigning a new one if first seen. + /// The returned value is a Spectre.Console markup-compatible hex color (e.g. #2CB7BD). + /// + public string GetColor(string resourceName) + { + var index = _palette.GetColorIndex(resourceName); + var variableName = ColorGenerator.s_variableNames[index]; + return s_hexColors[variableName]; + } /// - /// Gets the color assigned to the specified resource name, assigning a new one if first seen. + /// Pre-resolves colors for all provided resource names in sorted order so that + /// color assignment is deterministic regardless of encounter order. /// - public Color GetColor(string resourceName) + public void ResolveAll(IEnumerable resourceNames) { - if (!_colorMap.TryGetValue(resourceName, out var color)) - { - color = s_resourceColors[_nextColorIndex % s_resourceColors.Length]; - _colorMap[resourceName] = color; - _nextColorIndex++; - } - return color; + _palette.ResolveAll(resourceNames); } } + diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index b4db21d5100..4031bc79b01 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -280,6 +280,7 @@ + diff --git a/src/Aspire.Dashboard/Components/_Imports.razor b/src/Aspire.Dashboard/Components/_Imports.razor index 73b63480469..4a850f9045f 100644 --- a/src/Aspire.Dashboard/Components/_Imports.razor +++ b/src/Aspire.Dashboard/Components/_Imports.razor @@ -13,6 +13,7 @@ @using Microsoft.FluentUI.AspNetCore.Components @using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons @using Microsoft.JSInterop +@using Aspire @using Aspire.Dashboard @using Aspire.Dashboard.Components @using Aspire.Dashboard.Components.Controls diff --git a/src/Aspire.Dashboard/Model/Assistant/Markdown/ResourceInlineRenderer.cs b/src/Aspire.Dashboard/Model/Assistant/Markdown/ResourceInlineRenderer.cs index 07e9dd254cc..6a90a5e4f5c 100644 --- a/src/Aspire.Dashboard/Model/Assistant/Markdown/ResourceInlineRenderer.cs +++ b/src/Aspire.Dashboard/Model/Assistant/Markdown/ResourceInlineRenderer.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Encodings.Web; -using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Utils; using Markdig.Renderers; using Markdig.Renderers.Html; diff --git a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs index 96f1abd5ffa..68c2f737a9f 100644 --- a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs +++ b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs @@ -3,7 +3,6 @@ using System.Collections.Immutable; using System.Xml.Linq; -using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Resources; using Microsoft.Extensions.Localization; using Microsoft.FluentUI.AspNetCore.Components; diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 8f9b9b8578a..7cdfabdd67d 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -422,6 +422,15 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C { throw new FormatException($"Unexpected {nameof(WatchResourcesUpdate)} kind: {response.KindCase}"); } + + // Resolve resource colors for all resources so that color assignment is + // deterministic of order returned from the service, not order that the color for a resource is first used. + if (changes is not null) + { + var resolvedNames = _resourceByName.Values + .Select(r => ResourceViewModel.GetResourceName(r, _resourceByName)); + ColorGenerator.Instance.ResolveAll(resolvedNames); + } } if (changes is not null) diff --git a/src/Aspire.Dashboard/Otlp/Model/ColorGenerator.cs b/src/Shared/ColorGenerator.cs similarity index 63% rename from src/Aspire.Dashboard/Otlp/Model/ColorGenerator.cs rename to src/Shared/ColorGenerator.cs index f5af53cd2dc..4feeff13055 100644 --- a/src/Aspire.Dashboard/Otlp/Model/ColorGenerator.cs +++ b/src/Shared/ColorGenerator.cs @@ -3,26 +3,33 @@ using System.Collections.Concurrent; -namespace Aspire.Dashboard.Otlp.Model; +namespace Aspire; -public sealed class AccentColor +internal sealed class AccentColor { - public AccentColor(string variableName) + internal AccentColor(string variableName) { VariableName = variableName; ReferencedVariableName = $"var({variableName})"; } - public string VariableName { get; } - public string ReferencedVariableName { get; } + internal string VariableName { get; } + internal string ReferencedVariableName { get; } } /// -/// Provides a stable color for a named element. When is invoked a new color is returned if the key was not used previously. An instance of this class is thread-safe and multiple threads can query colors concurrently without collisions. +/// Provides a stable color for a named element. When +/// is invoked a new color is returned if the key was not used previously. An instance of this class +/// is thread-safe and multiple threads can query colors concurrently without collisions. +/// The palette of CSS variable names is shared between the dashboard and the CLI so that a given +/// resource name always receives the same color regardless of where it is displayed. /// -public class ColorGenerator +internal class ColorGenerator { - private static readonly string[] s_variableNames = + /// + /// The ordered list of CSS accent variable names used as the color palette. + /// + internal static readonly string[] s_variableNames = [ "--accent-teal", "--accent-marigold", @@ -45,13 +52,14 @@ public class ColorGenerator "--accent-jade", "--accent-olive" ]; + public static readonly ColorGenerator Instance = new ColorGenerator(); private readonly List _colors; private readonly ConcurrentDictionary> _colorIndexByKey; private int _currentIndex; - private ColorGenerator() + internal ColorGenerator() { _colors = new List(); _colorIndexByKey = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); @@ -63,7 +71,7 @@ private ColorGenerator() } } - private int GetColorIndex(string key) + internal int GetColorIndex(string key) { return _colorIndexByKey.GetOrAdd(key, k => { @@ -84,6 +92,18 @@ public string GetColorVariableByKey(string key) return _colors[i].ReferencedVariableName; } + /// + /// Pre-resolves colors for all provided keys in sorted order so that + /// color assignment is deterministic regardless of encounter order. + /// + public void ResolveAll(IEnumerable keys) + { + foreach (var key in keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) + { + GetColorIndex(key); + } + } + public void Clear() { _colorIndexByKey.Clear(); diff --git a/tests/Aspire.Cli.Tests/Utils/ResourceColorMapTests.cs b/tests/Aspire.Cli.Tests/Utils/ResourceColorMapTests.cs new file mode 100644 index 00000000000..c452f6d9b74 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/ResourceColorMapTests.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class ResourceColorMapTests +{ + [Fact] + public void HexColorsKeysMatchVariableNames() + { + Assert.Equal(ColorGenerator.s_variableNames.Length, ResourceColorMap.s_hexColors.Count); + + foreach (var variableName in ColorGenerator.s_variableNames) + { + Assert.True(ResourceColorMap.s_hexColors.ContainsKey(variableName), $"Missing key: {variableName}"); + } + } + + [Fact] + public void GetColorReturnsDeterministicResult() + { + var map = new ResourceColorMap(); + var color1 = map.GetColor("test-resource"); + var color2 = map.GetColor("test-resource"); + + Assert.Equal(color1, color2); + } + + [Fact] + public void ResolveAllMakesColorAssignmentDeterministic() + { + var map1 = new ResourceColorMap(); + map1.ResolveAll(["bravo", "alpha", "charlie"]); + + var map2 = new ResourceColorMap(); + map2.ResolveAll(["charlie", "alpha", "bravo"]); + + Assert.Equal(map1.GetColor("alpha"), map2.GetColor("alpha")); + Assert.Equal(map1.GetColor("bravo"), map2.GetColor("bravo")); + Assert.Equal(map1.GetColor("charlie"), map2.GetColor("charlie")); + } + + [Fact] + public void DifferentNamesGetDifferentColors() + { + var map = new ResourceColorMap(); + var color1 = map.GetColor("resource-a"); + var color2 = map.GetColor("resource-b"); + + Assert.NotEqual(color1, color2); + } +} From 098531fed1d71fed2936bfd31df2bf456a6fbde9 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 2 Mar 2026 23:50:47 -0500 Subject: [PATCH 13/18] Add status bar item showing Aspire app host status (#14869) --- extension/package.nls.json | 9 ++ extension/src/extension.ts | 20 ++-- extension/src/loc/strings.ts | 18 +++ .../src/views/AspireAppHostTreeProvider.ts | 16 ++- .../src/views/AspireStatusBarProvider.ts | 105 ++++++++++++++++++ 5 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 extension/src/views/AspireStatusBarProvider.ts diff --git a/extension/package.nls.json b/extension/package.nls.json index 293799ef081..e5eb3ee8891 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -99,6 +99,15 @@ "aspire-vscode.strings.dismissLabel": "Dismiss", "aspire-vscode.strings.selectDirectoryTitle": "Select directory", "aspire-vscode.strings.selectFileTitle": "Select file", + "aspire-vscode.strings.statusBarStopped": "Aspire: Stopped", + "aspire-vscode.strings.statusBarError": "Aspire: Error", + "aspire-vscode.strings.statusBarRunning": "Aspire: {0}/{1} running", + "aspire-vscode.strings.statusBarRunningNoResourcesSingular": "Aspire: {0} apphost", + "aspire-vscode.strings.statusBarRunningNoResourcesPlural": "Aspire: {0} apphosts", + "aspire-vscode.strings.statusBarTooltipStopped": "No Aspire apphosts running. Click to open the Aspire panel.", + "aspire-vscode.strings.statusBarTooltipError": "Error fetching Aspire apphost status. Click to open the Aspire panel.", + "aspire-vscode.strings.statusBarTooltipRunningSingular": "{0} Aspire apphost running. Click to open the Aspire panel.", + "aspire-vscode.strings.statusBarTooltipRunningPlural": "{0} Aspire apphosts running. Click to open the Aspire panel.", "viewsContainers.aspirePanel.title": "Aspire", "views.runningAppHosts.name": "Running apphosts", "views.runningAppHosts.welcome": "No running Aspire apphosts found.\n[Run an apphost](command:aspire-vscode.runAppHost)", diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 1878b2930a5..2e159fcc92b 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -26,6 +26,7 @@ import { openLocalSettingsCommand, openGlobalSettingsCommand } from './commands/ import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace } from './utils/workspace'; import { AspireEditorCommandProvider } from './editor/AspireEditorCommandProvider'; import { AspireAppHostTreeProvider } from './views/AspireAppHostTreeProvider'; +import { AspireStatusBarProvider } from './views/AspireStatusBarProvider'; let aspireExtensionContext = new AspireExtensionContext(); @@ -84,21 +85,16 @@ export async function activate(context: vscode.ExtensionContext) { // Set initial context for welcome view vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', true); - // Start polling when the tree view becomes visible, stop when hidden - if (appHostTreeView.visible) { - appHostTreeProvider.startPolling(); - } - - appHostTreeView.onDidChangeVisibility(e => { - if (e.visible) { - appHostTreeProvider.startPolling(); - } else { - appHostTreeProvider.stopPolling(); - } - }); + // Always poll for app host status — the status bar needs up-to-date data even + // when the tree view panel is hidden. + appHostTreeProvider.startPolling(); context.subscriptions.push(appHostTreeView, refreshRunningAppHostsRegistration, openDashboardRegistration, stopAppHostRegistration, stopResourceRegistration, startResourceRegistration, restartResourceRegistration, viewResourceLogsRegistration, executeResourceCommandRegistration, { dispose: () => appHostTreeProvider.dispose() }); + // Status bar + const statusBarProvider = new AspireStatusBarProvider(appHostTreeProvider); + context.subscriptions.push(statusBarProvider); + context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration); context.subscriptions.push(cliUpdateCommandRegistration, cliUpdateSelfCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 03821ecdaca..44d743af0d9 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -85,3 +85,21 @@ export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PAT export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path); export const selectDirectoryTitle = vscode.l10n.t('Select directory'); export const selectFileTitle = vscode.l10n.t('Select file'); + +// Status bar strings +export const statusBarStopped = vscode.l10n.t('Aspire: Stopped'); +export const statusBarError = vscode.l10n.t('Aspire: Error'); +export function statusBarRunning(appHostCount: number, runningResources: number, totalResources: number): string { + if (totalResources === 0) { + return appHostCount === 1 + ? vscode.l10n.t('Aspire: {0} apphost', appHostCount) + : vscode.l10n.t('Aspire: {0} apphosts', appHostCount); + } + return vscode.l10n.t('Aspire: {0}/{1} running', runningResources, totalResources); +} +export const statusBarTooltipStopped = vscode.l10n.t('No Aspire apphosts running. Click to open the Aspire panel.'); +export const statusBarTooltipError = vscode.l10n.t('Error fetching Aspire apphost status. Click to open the Aspire panel.'); +export const statusBarTooltipRunning = (appHostCount: number) => + appHostCount === 1 + ? vscode.l10n.t('{0} Aspire apphost running. Click to open the Aspire panel.', appHostCount) + : vscode.l10n.t('{0} Aspire apphosts running. Click to open the Aspire panel.', appHostCount); diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 863656293d4..5178b99e7bc 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -14,18 +14,18 @@ import { selectCommandPlaceholder, } from '../loc/strings'; -interface ResourceUrlJson { +export interface ResourceUrlJson { name: string | null; displayName: string | null; url: string; isInternal: boolean; } -interface ResourceCommandJson { +export interface ResourceCommandJson { description: string | null; } -interface ResourceJson { +export interface ResourceJson { name: string; displayName: string | null; resourceType: string; @@ -36,7 +36,7 @@ interface ResourceJson { commands: Record | null; } -interface AppHostDisplayInfo { +export interface AppHostDisplayInfo { appHostPath: string; appHostPid: number; cliPid: number | null; @@ -153,6 +153,14 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider this._update(treeProvider)) + ); + + this._update(treeProvider); + } + + private _update(treeProvider: AspireAppHostTreeProvider): void { + const appHosts = treeProvider.appHosts; + + if (treeProvider.hasError) { + this._statusBarItem.text = `$(error) ${statusBarError}`; + this._statusBarItem.tooltip = statusBarTooltipError; + this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); + this._statusBarItem.show(); + return; + } + + if (appHosts.length === 0) { + this._statusBarItem.text = `$(circle-outline) ${statusBarStopped}`; + this._statusBarItem.tooltip = statusBarTooltipStopped; + this._statusBarItem.backgroundColor = undefined; + this._statusBarItem.show(); + return; + } + + const { total, running } = countResources(appHosts); + const unhealthy = hasUnhealthyResource(appHosts); + + if (total === 0) { + // App host running but no resource info (older CLI without --resources) + this._statusBarItem.text = `$(radio-tower) ${statusBarRunning(appHosts.length, 0, 0)}`; + this._statusBarItem.tooltip = statusBarTooltipRunning(appHosts.length); + this._statusBarItem.backgroundColor = undefined; + this._statusBarItem.show(); + return; + } + + const icon = unhealthy ? '$(warning)' : '$(radio-tower)'; + this._statusBarItem.text = `${icon} ${statusBarRunning(appHosts.length, running, total)}`; + this._statusBarItem.tooltip = statusBarTooltipRunning(appHosts.length); + this._statusBarItem.backgroundColor = unhealthy + ? new vscode.ThemeColor('statusBarItem.warningBackground') + : undefined; + this._statusBarItem.show(); + } + + dispose(): void { + this._statusBarItem.dispose(); + for (const d of this._disposables) { + d.dispose(); + } + } +} From d8157f8385480b6a23b701b8eef589611c492c6a Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 3 Mar 2026 12:57:10 +0800 Subject: [PATCH 14/18] Replace ANSI escape codes with Live display, remove IAnsiConsole dependency, and simplify status feedback (#14871) --- src/Aspire.Cli/Commands/AddCommand.cs | 10 +- src/Aspire.Cli/Commands/AppHostLauncher.cs | 109 ++++++++-------- src/Aspire.Cli/Commands/ExecCommand.cs | 4 - src/Aspire.Cli/Commands/RunCommand.cs | 120 ++++++++++-------- .../Interaction/ConsoleInteractionService.cs | 10 ++ .../ExtensionInteractionService.cs | 5 + .../Interaction/IInteractionService.cs | 1 + src/Aspire.Cli/Projects/ProjectLocator.cs | 1 - .../Resources/AddCommandStrings.Designer.cs | 7 - .../Resources/AddCommandStrings.resx | 3 - .../Resources/RunCommandStrings.Designer.cs | 6 - .../Resources/RunCommandStrings.resx | 3 - .../Resources/xlf/AddCommandStrings.cs.xlf | 5 - .../Resources/xlf/AddCommandStrings.de.xlf | 5 - .../Resources/xlf/AddCommandStrings.es.xlf | 5 - .../Resources/xlf/AddCommandStrings.fr.xlf | 5 - .../Resources/xlf/AddCommandStrings.it.xlf | 5 - .../Resources/xlf/AddCommandStrings.ja.xlf | 5 - .../Resources/xlf/AddCommandStrings.ko.xlf | 5 - .../Resources/xlf/AddCommandStrings.pl.xlf | 5 - .../Resources/xlf/AddCommandStrings.pt-BR.xlf | 5 - .../Resources/xlf/AddCommandStrings.ru.xlf | 5 - .../Resources/xlf/AddCommandStrings.tr.xlf | 5 - .../xlf/AddCommandStrings.zh-Hans.xlf | 5 - .../xlf/AddCommandStrings.zh-Hant.xlf | 5 - .../Resources/xlf/RunCommandStrings.cs.xlf | 5 - .../Resources/xlf/RunCommandStrings.de.xlf | 5 - .../Resources/xlf/RunCommandStrings.es.xlf | 5 - .../Resources/xlf/RunCommandStrings.fr.xlf | 5 - .../Resources/xlf/RunCommandStrings.it.xlf | 5 - .../Resources/xlf/RunCommandStrings.ja.xlf | 5 - .../Resources/xlf/RunCommandStrings.ko.xlf | 5 - .../Resources/xlf/RunCommandStrings.pl.xlf | 5 - .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 5 - .../Resources/xlf/RunCommandStrings.ru.xlf | 5 - .../Resources/xlf/RunCommandStrings.tr.xlf | 5 - .../xlf/RunCommandStrings.zh-Hans.xlf | 5 - .../xlf/RunCommandStrings.zh-Hant.xlf | 5 - .../Commands/NewCommandTests.cs | 1 + ...PublishCommandPromptingIntegrationTests.cs | 1 + .../Commands/UpdateCommandTests.cs | 1 + .../Templating/DotNetTemplateFactoryTests.cs | 1 + .../TestConsoleInteractionService.cs | 5 + .../TestExtensionInteractionService.cs | 5 + 44 files changed, 152 insertions(+), 271 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index cd8bac92719..ef7bb20a7bb 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -212,12 +212,10 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => // which prevents 'dotnet add package' from modifying the project. if (_features.IsFeatureEnabled(KnownFeatures.RunningInstanceDetectionEnabled, defaultValue: true)) { - var runningInstanceResult = await InteractionService.ShowStatusAsync( - AddCommandStrings.CheckingForRunningInstances, - async () => await project.FindAndStopRunningInstanceAsync( - effectiveAppHostProjectFile, - ExecutionContext.HomeDirectory, - cancellationToken)); + var runningInstanceResult = await project.FindAndStopRunningInstanceAsync( + effectiveAppHostProjectFile, + ExecutionContext.HomeDirectory, + cancellationToken); if (runningInstanceResult == RunningInstanceResult.InstanceStopped) { diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 7ba03516eab..2eeb0a397d0 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -13,7 +13,6 @@ using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; -using Spectre.Console; namespace Aspire.Cli.Commands; @@ -27,7 +26,6 @@ internal sealed class AppHostLauncher( CliExecutionContext executionContext, IFeatures features, IInteractionService interactionService, - IAnsiConsole ansiConsole, IAuxiliaryBackchannelMonitor backchannelMonitor, ILogger logger, TimeProvider timeProvider) @@ -122,7 +120,9 @@ public async Task LaunchDetachedAsync( logger.LogDebug("Waiting for socket with prefix: {SocketPrefix}, Hash: {Hash}", expectedSocketPrefix, expectedHash); // Start the child process and wait for the backchannel - var launchResult = await LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, cancellationToken); + var launchResult = await interactionService.ShowStatusAsync( + RunCommandStrings.StartingAppHostInBackground, + () => LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, cancellationToken)); // Handle failure cases if (launchResult.Backchannel is null || launchResult.ChildProcess is null) @@ -131,7 +131,7 @@ public async Task LaunchDetachedAsync( } // Display results - await DisplayLaunchResultAsync(launchResult, effectiveAppHostFile, childLogFile, format, isExtensionHost, cancellationToken); + DisplayLaunchResult(launchResult, effectiveAppHostFile, childLogFile, format, isExtensionHost); return ExitCodeConstants.Success; } @@ -203,7 +203,7 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can return (dotnetPath, childArgs); } - private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, bool ChildExitedEarly, int ChildExitCode); + private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode); private async Task LaunchAndWaitForBackchannelAsync( string executablePath, @@ -211,68 +211,66 @@ private async Task LaunchAndWaitForBackchannelAsync( string expectedHash, CancellationToken cancellationToken) { - Process? childProcess = null; - var childExitedEarly = false; - var childExitCode = 0; + Process childProcess; - async Task WaitForBackchannelAsync() + try { - try - { - childProcess = DetachedProcessLauncher.Start( - executablePath, - childArgs, - executionContext.WorkingDirectory.FullName); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to start child CLI process"); - return null; - } - - logger.LogDebug("Child CLI process started with PID: {PID}", childProcess.Id); + childProcess = DetachedProcessLauncher.Start( + executablePath, + childArgs, + executionContext.WorkingDirectory.FullName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start child CLI process"); + return new LaunchResult(null, null, null, false, 0); + } - var startTime = timeProvider.GetUtcNow(); - var timeout = TimeSpan.FromSeconds(120); + logger.LogDebug("Child CLI process started with PID: {PID}", childProcess.Id); - while (timeProvider.GetUtcNow() - startTime < timeout) - { - cancellationToken.ThrowIfCancellationRequested(); + var startTime = timeProvider.GetUtcNow(); + var timeout = TimeSpan.FromSeconds(120); - if (childProcess.HasExited) - { - childExitedEarly = true; - childExitCode = childProcess.ExitCode; - logger.LogWarning("Child CLI process exited with code {ExitCode}", childExitCode); - return null; - } + while (timeProvider.GetUtcNow() - startTime < timeout) + { + cancellationToken.ThrowIfCancellationRequested(); - await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + if (childProcess.HasExited) + { + var exitCode = childProcess.ExitCode; + logger.LogWarning("Child CLI process exited with code {ExitCode}", exitCode); + return new LaunchResult(childProcess, null, null, true, exitCode); + } - var connection = backchannelMonitor.GetConnectionsByHash(expectedHash).FirstOrDefault(); - if (connection is not null) - { - return connection; - } + await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + var connection = backchannelMonitor.GetConnectionsByHash(expectedHash).FirstOrDefault(); + if (connection is not null) + { + DashboardUrlsState? dashboardUrls = null; try { - await childProcess.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false); + dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); } - catch (TimeoutException) + catch (Exception ex) { - // Expected - the 500ms delay elapsed without the process exiting + logger.LogDebug(ex, "Failed to retrieve dashboard URLs from backchannel connection. Continuing without dashboard URLs."); } + + return new LaunchResult(childProcess, connection, dashboardUrls, false, 0); } - return null; + try + { + await childProcess.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Expected - the 500ms delay elapsed without the process exiting + } } - var backchannel = await interactionService.ShowStatusAsync( - RunCommandStrings.StartingAppHostInBackground, - WaitForBackchannelAsync); - - return new LaunchResult(childProcess, backchannel, childExitedEarly, childExitCode); + return new LaunchResult(childProcess, null, null, false, 0); } private int HandleLaunchFailure(LaunchResult result, string childLogFile) @@ -312,16 +310,15 @@ private int HandleLaunchFailure(LaunchResult result, string childLogFile) return ExitCodeConstants.FailedToDotnetRunAppHost; } - private async Task DisplayLaunchResultAsync( + private void DisplayLaunchResult( LaunchResult result, FileInfo effectiveAppHostFile, string childLogFile, OutputFormat? format, - bool isExtensionHost, - CancellationToken cancellationToken) + bool isExtensionHost) { var appHostInfo = result.Backchannel!.AppHostInfo; - var dashboardUrls = await result.Backchannel.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); + var dashboardUrls = result.DashboardUrls; var pid = appHostInfo?.ProcessId ?? result.ChildProcess!.Id; if (format == OutputFormat.Json) @@ -339,14 +336,14 @@ private async Task DisplayLaunchResultAsync( { var appHostRelativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, effectiveAppHostFile.FullName); RunCommand.RenderAppHostSummary( - ansiConsole, + interactionService, appHostRelativePath, dashboardUrls?.BaseUrlWithLoginToken, codespacesUrl: null, childLogFile, isExtensionHost, pid); - ansiConsole.WriteLine(); + interactionService.DisplayEmptyLine(); interactionService.DisplaySuccess(RunCommandStrings.AppHostStartedSuccessfully); } diff --git a/src/Aspire.Cli/Commands/ExecCommand.cs b/src/Aspire.Cli/Commands/ExecCommand.cs index abf14c52d3f..ba87ea2e045 100644 --- a/src/Aspire.Cli/Commands/ExecCommand.cs +++ b/src/Aspire.Cli/Commands/ExecCommand.cs @@ -13,7 +13,6 @@ using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Aspire.Hosting; -using Spectre.Console; namespace Aspire.Cli.Commands; @@ -22,7 +21,6 @@ internal class ExecCommand : BaseCommand private readonly IDotNetCliRunner _runner; private readonly ICertificateService _certificateService; private readonly IProjectLocator _projectLocator; - private readonly IAnsiConsole _ansiConsole; private readonly IDotNetSdkInstaller _sdkInstaller; private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", ExecCommandStrings.ProjectArgumentDescription); @@ -48,7 +46,6 @@ public ExecCommand( IInteractionService interactionService, ICertificateService certificateService, IProjectLocator projectLocator, - IAnsiConsole ansiConsole, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, @@ -59,7 +56,6 @@ public ExecCommand( _runner = runner; _certificateService = certificateService; _projectLocator = projectLocator; - _ansiConsole = ansiConsole; _sdkInstaller = sdkInstaller; Options.Add(s_appHostOption); diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index f2e71ee748c..e13252936f2 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -19,6 +19,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Spectre.Console; +using Spectre.Console.Rendering; using StreamJsonRpc; namespace Aspire.Cli.Commands; @@ -58,7 +59,6 @@ internal sealed class RunCommand : BaseCommand private readonly IInteractionService _interactionService; private readonly ICertificateService _certificateService; private readonly IProjectLocator _projectLocator; - private readonly IAnsiConsole _ansiConsole; private readonly IConfiguration _configuration; private readonly IServiceProvider _serviceProvider; private readonly IFeatures _features; @@ -85,7 +85,6 @@ public RunCommand( IInteractionService interactionService, ICertificateService certificateService, IProjectLocator projectLocator, - IAnsiConsole ansiConsole, AspireCliTelemetry telemetry, IConfiguration configuration, IFeatures features, @@ -102,7 +101,6 @@ public RunCommand( _interactionService = interactionService; _certificateService = certificateService; _projectLocator = projectLocator; - _ansiConsole = ansiConsole; _configuration = configuration; _serviceProvider = serviceProvider; _features = features; @@ -206,9 +204,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Even if we fail to stop we won't block the apphost starting // to make sure we don't ever break flow. It should mostly stop // just fine though. - var runningInstanceResult = await InteractionService.ShowStatusAsync( - RunCommandStrings.CheckingForRunningInstances, - async () => await project.FindAndStopRunningInstanceAsync(effectiveAppHostFile, ExecutionContext.HomeDirectory, cancellationToken)); + var runningInstanceResult = await project.FindAndStopRunningInstanceAsync(effectiveAppHostFile, ExecutionContext.HomeDirectory, cancellationToken); // If in isolated mode and a running instance was stopped, warn the user if (isolated && runningInstanceResult == RunningInstanceResult.InstanceStopped) @@ -276,7 +272,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Display the UX var appHostRelativePath = Path.GetRelativePath(ExecutionContext.WorkingDirectory.FullName, effectiveAppHostFile.FullName); var longestLocalizedLengthWithColon = RenderAppHostSummary( - _ansiConsole, + InteractionService, appHostRelativePath, dashboardUrls.BaseUrlWithLoginToken, dashboardUrls.CodespacesUrlWithLoginToken, @@ -288,45 +284,69 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var isRemoteContainers = string.Equals(_configuration["REMOTE_CONTAINERS"], "true", StringComparison.OrdinalIgnoreCase); var isSshRemote = _configuration["VSCODE_IPC_HOOK_CLI"] is not null && _configuration["SSH_CONNECTION"] is not null; + var isRemoteEnvironment = isCodespaces || isRemoteContainers || isSshRemote; - AppendCtrlCMessage(longestLocalizedLengthWithColon); - - if (isCodespaces || isRemoteContainers || isSshRemote) + if (!isRemoteEnvironment) { - bool firstEndpoint = true; + AppendCtrlCMessage(longestLocalizedLengthWithColon); + } + else + { + // We want to display resource information in remote environments. + // Resources update over time so we'll use a live display. + // It is used to show discovered endpoints as they come in over the backchannel. + var discoveredEndpoints = new List<(string Resource, string Endpoint)>(); var endpointsLocalizedString = RunCommandStrings.Endpoints; + var showCtrlC = !ExtensionHelper.IsExtensionHost(_interactionService, out _, out _); - try + IRenderable BuildLiveRenderable() { - var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken); - await foreach (var resourceState in resourceStates.WithCancellation(cancellationToken)) - { - ProcessResourceState(resourceState, (resource, endpoint) => - { - ClearLines(2); - - var endpointsGrid = new Grid(); - endpointsGrid.AddColumn(); - endpointsGrid.AddColumn(); - endpointsGrid.Columns[0].Width = longestLocalizedLengthWithColon; + var rows = new List(); - if (firstEndpoint) - { - endpointsGrid.AddRow(Text.Empty, Text.Empty); - } + if (discoveredEndpoints.Count > 0) + { + var endpointsGrid = new Grid(); + endpointsGrid.AddColumn(); + endpointsGrid.AddColumn(); + endpointsGrid.Columns[0].Width = longestLocalizedLengthWithColon; + endpointsGrid.AddRow(Text.Empty, Text.Empty); + for (var i = 0; i < discoveredEndpoints.Count; i++) + { + var (resource, endpoint) = discoveredEndpoints[i]; endpointsGrid.AddRow( - firstEndpoint ? new Align(new Markup($"[bold green]{endpointsLocalizedString}[/]:"), HorizontalAlignment.Right) : Text.Empty, + i == 0 + ? new Align(new Markup($"[bold green]{endpointsLocalizedString}[/]:"), HorizontalAlignment.Right) + : Text.Empty, new Markup($"[bold]{resource.EscapeMarkup()}[/] [grey]has endpoint[/] [link={endpoint.EscapeMarkup()}]{endpoint.EscapeMarkup()}[/]") ); + } - var endpointsPadder = new Padder(endpointsGrid, new Padding(3, 0)); - _ansiConsole.Write(endpointsPadder); - firstEndpoint = false; + rows.Add(new Padder(endpointsGrid, new Padding(3, 0))); + } - AppendCtrlCMessage(longestLocalizedLengthWithColon); - }); + if (showCtrlC) + { + rows.Add(BuildCtrlCRenderable(longestLocalizedLengthWithColon)); } + + return rows.Count > 0 ? new Rows(rows) : Text.Empty; + } + + try + { + await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTarget => + { + var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken); + await foreach (var resourceState in resourceStates.WithCancellation(cancellationToken)) + { + ProcessResourceState(resourceState, (resource, endpoint) => + { + discoveredEndpoints.Add((resource, endpoint)); + updateTarget(BuildLiveRenderable()); + }); + } + }); } catch (ConnectionLostException) when (cancellationToken.IsCancellationRequested) { @@ -384,18 +404,16 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } } - private void ClearLines(int lines) + private static IRenderable BuildCtrlCRenderable(int longestLocalizedLengthWithColon) { - if (lines <= 0) - { - return; - } + var ctrlCGrid = new Grid(); + ctrlCGrid.AddColumn(); + ctrlCGrid.AddColumn(); + ctrlCGrid.Columns[0].Width = longestLocalizedLengthWithColon; + ctrlCGrid.AddRow(Text.Empty, Text.Empty); + ctrlCGrid.AddRow(new Text(string.Empty), new Markup(RunCommandStrings.PressCtrlCToStopAppHost) { Overflow = Overflow.Ellipsis }); - for (var i = 0; i < lines; i++) - { - _ansiConsole.Write("\u001b[1A"); - _ansiConsole.Write("\u001b[2K"); // Clear the line - } + return new Padder(ctrlCGrid, new Padding(3, 0)); } private void AppendCtrlCMessage(int longestLocalizedLengthWithColon) @@ -405,15 +423,7 @@ private void AppendCtrlCMessage(int longestLocalizedLengthWithColon) return; } - var ctrlCGrid = new Grid(); - ctrlCGrid.AddColumn(); - ctrlCGrid.AddColumn(); - ctrlCGrid.Columns[0].Width = longestLocalizedLengthWithColon; - ctrlCGrid.AddRow(Text.Empty, Text.Empty); - ctrlCGrid.AddRow(new Text(string.Empty), new Markup(RunCommandStrings.PressCtrlCToStopAppHost) { Overflow = Overflow.Ellipsis }); - - var ctrlCPadder = new Padder(ctrlCGrid, new Padding(3, 0)); - _ansiConsole.Write(ctrlCPadder); + InteractionService.DisplayRenderable(BuildCtrlCRenderable(longestLocalizedLengthWithColon)); } /// @@ -428,7 +438,7 @@ private void AppendCtrlCMessage(int longestLocalizedLengthWithColon) /// Whether the AppHost is running in the Aspire extension. /// The column width used, for subsequent grid additions. internal static int RenderAppHostSummary( - IAnsiConsole console, + IInteractionService console, string appHostRelativePath, string? dashboardUrl, string? codespacesUrl, @@ -436,7 +446,7 @@ internal static int RenderAppHostSummary( bool isExtensionHost, int? pid = null) { - console.WriteLine(); + console.DisplayEmptyLine(); var grid = new Grid(); grid.AddColumn(); grid.AddColumn(); @@ -501,7 +511,7 @@ internal static int RenderAppHostSummary( } var padder = new Padder(grid, new Padding(3, 0)); - console.Write(padder); + console.DisplayRenderable(padder); return longestLabelLength; } diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index c3e9188f252..fd89dcffe37 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -309,6 +309,16 @@ public void DisplayRenderable(IRenderable renderable) MessageConsole.Write(renderable); } + public async Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) + { + await MessageConsole.Live(initialRenderable) + .AutoClear(false) + .StartAsync(async ctx => + { + await callback(renderable => ctx.UpdateTarget(renderable)); + }); + } + public void DisplayCancellationMessage() { MessageConsole.WriteLine(); diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index f73d543b21f..fcdaa1f706f 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -401,6 +401,11 @@ public void DisplayRenderable(IRenderable renderable) _consoleInteractionService.DisplayRenderable(renderable); } + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) + { + return _consoleInteractionService.DisplayLiveAsync(initialRenderable, callback); + } + public void LogMessage(LogLevel logLevel, string message) { var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.LogMessageAsync(logLevel, message.RemoveSpectreFormatting(), _cancellationToken)); diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index 69f7c1a7bd4..fc55774caad 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -27,6 +27,7 @@ internal interface IInteractionService void DisplaySubtleMessage(string message, bool allowMarkup = false); void DisplayLines(IEnumerable<(string Stream, string Line)> lines); void DisplayRenderable(IRenderable renderable); + Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback); void DisplayCancellationMessage(); void DisplayEmptyLine(); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index cbae81bc135..aef5bad337a 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -255,7 +255,6 @@ public async Task UseOrFindAppHostProjectFileAsync(F logger.LogDebug("No project file specified, searching for apphost projects in {CurrentDirectory}", executionContext.WorkingDirectory); var results = await FindAppHostProjectFilesAsync(executionContext.WorkingDirectory, cancellationToken); - interactionService.DisplayEmptyLine(); logger.LogDebug("Found {ProjectFileCount} project files.", results.BuildableAppHost.Count); diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs index 71c85806aaa..f71005d01f8 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs @@ -205,12 +205,5 @@ public static string UnableToStopRunningInstances } } - public static string CheckingForRunningInstances - { - get - { - return ResourceManager.GetString("CheckingForRunningInstances", resourceCulture); - } - } } } diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.resx b/src/Aspire.Cli/Resources/AddCommandStrings.resx index 1c6d2d5e594..84623e973d3 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AddCommandStrings.resx @@ -178,7 +178,4 @@ Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. - - Checking for running instances... - diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs index 73fd7e4da7f..519c1ce461b 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs @@ -207,12 +207,6 @@ public static string RunningInstanceStopped { } } - public static string CheckingForRunningInstances { - get { - return ResourceManager.GetString("CheckingForRunningInstances", resourceCulture); - } - } - public static string DetachArgumentDescription { get { return ResourceManager.GetString("DetachArgumentDescription", resourceCulture); diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index 293f22bb345..b9e4bc0045b 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -208,9 +208,6 @@ Running instance stopped successfully. - - Checking for running instances... - Starting Aspire apphost in the background... diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf index 0f8510ed398..bb24461c342 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf @@ -7,11 +7,6 @@ Přidává se integrace hostování Aspire... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Přidejte do hostitele aplikací Aspire integraci hostování. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf index 14767e1b9d4..d78167fb247 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf @@ -7,11 +7,6 @@ Aspire-Hosting-Integration wird hinzugefügt... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Fügen Sie dem Aspire AppHost eine Hosting-Integration hinzu. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf index e4f29db42e6..e338b59f460 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf @@ -7,11 +7,6 @@ Agregando integración de hospedaje de Aspire... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Agregue una integración de hospedaje a Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf index 85411608591..5571a70a18b 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf @@ -7,11 +7,6 @@ Ajout de l’intégration d’hébergement Aspire... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Ajoutez une intégration d’hébergement à l’Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf index 4b58d0c925a..c3247c18f84 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf @@ -7,11 +7,6 @@ Aggiunta dell'integrazione di hosting Aspire in corso... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Aggiungere un'integrazione di hosting all'AppHost Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf index 4e1b1aa4004..90add5ffb24 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf @@ -7,11 +7,6 @@ Aspire ホスティング統合を追加しています... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Aspire AppHost にホスティング統合を追加します。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf index 1fb392ff16e..1c03456afca 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf @@ -7,11 +7,6 @@ Aspire 호스팅 통합을 추가하는 중... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Aspire AppHost에 호스팅 통합을 추가하세요. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf index 80e171960a2..54c15d16df9 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf @@ -7,11 +7,6 @@ Trwa dodawanie integracji hostingu platformy Aspire... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Dodaj integrację hostingu do hosta AppHost platformy Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf index 174232e941e..5ef5e738086 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf @@ -7,11 +7,6 @@ Adicionando integração de hosting do Aspire... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Adicione uma integração de hosting ao Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf index 97af4404f3a..e245c37f520 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf @@ -7,11 +7,6 @@ Добавление интеграции размещения Aspire... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Добавьте интеграцию размещения в Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf index 0dfe380f9b3..3b2e6350c61 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf @@ -7,11 +7,6 @@ Aspire barındırma tümleştirmesi ekleniyor... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. Aspire AppHost'a bir barındırma tümleştirmesi ekleyin. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf index f55a676a7c6..563019e9b67 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf @@ -7,11 +7,6 @@ 正在添加 Aspire 托管集成... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. 将托管集成添加到 Aspire AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf index e2c1b243074..1a196dee737 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf @@ -7,11 +7,6 @@ 正在新增 Aspire 主機整合... - - Checking for running instances... - Checking for running instances... - - Add a hosting integration to the apphost. 將主機整合新增到 Aspire AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 408f4c0ff76..0e3657221a8 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -52,11 +52,6 @@ Podrobnosti najdete v protokolech: {0} - - Checking for running instances... - Kontrolují se spuštěné instance... - - Connecting to apphost... Připojování k hostiteli aplikací... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index fc752ab1cae..06e96c6ebf4 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -52,11 +52,6 @@ Details finden Sie in den Protokollen: {0} - - Checking for running instances... - Es wird nach ausgeführten Instanzen gesucht… - - Connecting to apphost... Verbindung zum AppHost wird hergestellt... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index f8dcdd2a279..b8e6d589e5d 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -52,11 +52,6 @@ Compruebe los registros para obtener más información: {0} - - Checking for running instances... - Comprobando si hay instancias en ejecución... - - Connecting to apphost... Conectando con apphost... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index acfc3b0e3c8..a6b974cf238 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -52,11 +52,6 @@ Pour en savoir plus, consultez les journaux : {0} - - Checking for running instances... - Vérification des instances en cours d’exécution... - - Connecting to apphost... Connexion à apphost... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index 6b846a3ad4c..014ccaae073 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -52,11 +52,6 @@ Controllare i log per i dettagli: {0} - - Checking for running instances... - Verifica delle istanze in esecuzione in corso... - - Connecting to apphost... Connessione all'AppHost Aspire in corso... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 7bade179271..680d38b946c 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -52,11 +52,6 @@ 詳細については、ログを確認してください: {0} - - Checking for running instances... - 実行中のインスタンスを確認しています... - - Connecting to apphost... AppHost に接続しています... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index 545d3372808..0fcbfa49490 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -52,11 +52,6 @@ 자세한 내용은 로그를 확인하세요. {0} - - Checking for running instances... - 실행 중인 인스턴스를 확인하는 중... - - Connecting to apphost... apphost에 연결하는 중... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index cb9b49f296d..c6c78fc36d4 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -52,11 +52,6 @@ Sprawdź dzienniki, aby uzyskać szczegółowe informacje: {0} - - Checking for running instances... - Sprawdzanie uruchomionych wystąpień... - - Connecting to apphost... Trwa łączenie z hostem AppHost... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index 15b25dbf161..662f51b7e04 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -52,11 +52,6 @@ Verifique os logs para obter detalhes: {0} - - Checking for running instances... - Verificando se há instâncias em execução... - - Connecting to apphost... Conectando ao apphost... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index acce9cd2a43..384894aef2b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -52,11 +52,6 @@ Просмотрите дополнительные сведения в журналах: {0} - - Checking for running instances... - Проверка запущенных экземпляров… - - Connecting to apphost... Подключение к хосту приложений... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index a4f08cabbf1..33eee32031e 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -52,11 +52,6 @@ Ayrıntılar için günlükleri kontrol edin: {0} - - Checking for running instances... - Çalışan örnekler kontrol ediliyor... - - Connecting to apphost... Apphost'a bağlanılıyor... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index e12006ca5e7..f233d92d5b5 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -52,11 +52,6 @@ 请查看日志了解详细信息: {0} - - Checking for running instances... - 正在检查正在运行的实例... - - Connecting to apphost... 正在连接到应用主机... diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index 9eed9ef8e4a..973f3588808 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -52,11 +52,6 @@ 檢查記錄以取得詳細資料: {0} - - Checking for running instances... - 正在檢查是否有正在執行的執行個體... - - Connecting to apphost... 正線連線到 AppHost... diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 83c1e43acde..2cc0cdc21e7 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1416,6 +1416,7 @@ public void DisplayMarkupLine(string markup) { } public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) { } public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { } public void DisplayRenderable(IRenderable renderable) { } + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); } internal sealed class NewCommandTestPackagingService : IPackagingService diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 3e659b2fe27..d32dee8cd71 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -958,6 +958,7 @@ public void DisplayMarkupLine(string markup) { } public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { } public void DisplayRenderable(IRenderable renderable) { } + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) { diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 80e0d0c5e58..aa1d32e3f1a 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -988,6 +988,7 @@ public void DisplayVersionUpdateNotification(string newerVersion, string? update public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) => _innerService.WriteConsoleLog(message, lineNumber, type, isErrorMessage); public void DisplayRenderable(IRenderable renderable) => _innerService.DisplayRenderable(renderable); + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => _innerService.DisplayLiveAsync(initialRenderable, callback); } // Test implementation of IProjectUpdater diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index f95b721dfe8..fdd0bc788e2 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -493,6 +493,7 @@ public void DisplayEmptyLine() { } public void DisplayVersionUpdateNotification(string message, string? updateCommand = null) { } public void WriteConsoleLog(string message, int? resourceHashCode, string? resourceName, bool isError) { } public void DisplayRenderable(IRenderable renderable) { } + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); } private sealed class TestDotNetCliRunner : IDotNetCliRunner diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs index 0bac2dbca30..b06f83018c2 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs @@ -148,4 +148,9 @@ public void DisplayVersionUpdateNotification(string newerVersion, string? update public void DisplayRenderable(IRenderable renderable) { } + + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) + { + return callback(_ => { }); + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index 069a27d9d52..07e82a5609f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -162,6 +162,11 @@ public void DisplayRenderable(IRenderable renderable) { } + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) + { + return callback(_ => { }); + } + public Action? OpenEditorCallback { get; set; } public void OpenEditor(string projectPath) From 518cd60928a3dce7f61e219fc951ac78477581ff Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 3 Mar 2026 17:26:30 +1100 Subject: [PATCH 15/18] Fix flaky E2E test: retry MSBuild evaluation on empty output (#14868) * chore: trigger CI build for issue #14819 investigation Whitespace change to trigger CI pipeline and E2E test execution for reproducing the flaky DetachFormatJsonProducesValidJson test. Fixes #14819 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix flaky E2E test: retry MSBuild evaluation on empty output When dotnet msbuild -getProperty/-getItem returns exit code 0 but produces no stdout output (likely due to MSBuild server contention when another AppHost process is running), JsonDocument.Parse would throw 'The input does not contain any JSON tokens', crashing the aspire run --detach --format json command. This fix adds retry logic (up to 3 attempts with increasing delay) for this specific case, making the project discovery more resilient to concurrent MSBuild operations. Fixes #14819 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 98 ++++++++++++------- .../MultipleAppHostTests.cs | 1 + 2 files changed, 64 insertions(+), 35 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 9514676f717..a4705c162f3 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -339,48 +339,76 @@ private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string string[] cliArgs = [.. cliArgsList]; - var stdoutBuilder = new StringBuilder(); - var existingStandardOutputCallback = options.StandardOutputCallback; // Preserve the existing callback if it exists. - options.StandardOutputCallback = (line) => { - stdoutBuilder.AppendLine(line); - existingStandardOutputCallback?.Invoke(line); - }; + var existingStandardOutputCallback = options.StandardOutputCallback; + var existingStandardErrorCallback = options.StandardErrorCallback; - var stderrBuilder = new StringBuilder(); - var existingStandardErrorCallback = options.StandardErrorCallback; // Preserve the existing callback if it exists. - options.StandardErrorCallback = (line) => { - stderrBuilder.AppendLine(line); - existingStandardErrorCallback?.Invoke(line); - }; + // Retry when MSBuild returns success but produces no output, which can happen + // due to MSBuild server contention (e.g. when another AppHost build is running). + const int maxRetries = 3; + for (var attempt = 0; attempt < maxRetries; attempt++) + { + var stdoutBuilder = new StringBuilder(); + options.StandardOutputCallback = (line) => { + stdoutBuilder.AppendLine(line); + existingStandardOutputCallback?.Invoke(line); + }; - var exitCode = await ExecuteAsync( - args: cliArgs, - env: null, - projectFile: projectFile, - workingDirectory: projectFile.Directory!, - backchannelCompletionSource: null, - options: options, - cancellationToken: cancellationToken); + var stderrBuilder = new StringBuilder(); + options.StandardErrorCallback = (line) => { + stderrBuilder.AppendLine(line); + existingStandardErrorCallback?.Invoke(line); + }; - var stdout = stdoutBuilder.ToString(); - var stderr = stderrBuilder.ToString(); + var exitCode = await ExecuteAsync( + args: cliArgs, + env: null, + projectFile: projectFile, + workingDirectory: projectFile.Directory!, + backchannelCompletionSource: null, + options: options, + cancellationToken: cancellationToken); - if (exitCode != 0) - { - logger.LogError( - "Failed to get items and properties from project. Exit code was: {ExitCode}. See debug logs for more details. Stderr: {Stderr}, Stdout: {Stdout}", - exitCode, - stderr, - stdout - ); + var stdout = stdoutBuilder.ToString(); + var stderr = stderrBuilder.ToString(); - return (exitCode, null); - } - else - { - var json = JsonDocument.Parse(stdout!); + if (exitCode != 0) + { + logger.LogError( + "Failed to get items and properties from project. Exit code was: {ExitCode}. See debug logs for more details. Stderr: {Stderr}, Stdout: {Stdout}", + exitCode, + stderr, + stdout + ); + + return (exitCode, null); + } + + if (string.IsNullOrWhiteSpace(stdout)) + { + if (attempt < maxRetries - 1) + { + logger.LogWarning( + "dotnet msbuild returned exit code 0 but produced no output (attempt {Attempt}/{MaxRetries}). Retrying after delay. Stderr: {Stderr}", + attempt + 1, + maxRetries, + stderr); + await Task.Delay(TimeSpan.FromSeconds(attempt + 1), cancellationToken).ConfigureAwait(false); + continue; + } + + logger.LogWarning( + "dotnet msbuild returned exit code 0 but produced no output after {MaxRetries} attempts. Stderr: {Stderr}", + maxRetries, + stderr); + return (exitCode, null); + } + + var json = JsonDocument.Parse(stdout); return (exitCode, json); } + + // Should not be reached, but return failure as a safety net + return (1, null); } public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs index b7b3ee942af..89b7f87a936 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs @@ -130,3 +130,4 @@ public async Task DetachFormatJsonProducesValidJson() await pendingRun; } } + From cae16ca76ddfc01043963ab98dd1775bb58a0a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Mon, 2 Mar 2026 22:32:55 -0800 Subject: [PATCH 16/18] Add polyglot exports for AppContainers (#14758) * Add polyglot exports for AppContainers * Update exports * Fix tests * Add test for new behaviors * Fix cancellation token wrappers in polyglot SDKs Avoid generating/ registering CancellationToken as a handle wrapper in Go, Java, and Rust generators, and update snapshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ValidationAppHost/.aspire/settings.json | 8 + .../ValidationAppHost/.modules/.codegen-hash | 1 + .../ValidationAppHost/.modules/aspire.ts | 5859 +++++++++++++++++ .../ValidationAppHost/.modules/base.ts | 453 ++ .../ValidationAppHost/.modules/transport.ts | 557 ++ .../ValidationAppHost/apphost.run.json | 11 + .../ValidationAppHost/apphost.ts | 68 + .../ValidationAppHost/package-lock.json | 962 +++ .../ValidationAppHost/package.json | 19 + .../ValidationAppHost/tsconfig.json | 15 + .../AtsTypeMappings.cs | 14 + .../AzureContainerAppContainerExtensions.cs | 1 + .../AzureContainerAppExecutableExtensions.cs | 1 + .../AzureContainerAppExtensions.cs | 6 + .../AzureContainerAppProjectExtensions.cs | 1 + .../ContainerAppExtensions.cs | 25 + .../AzureResourceInfrastructure.cs | 1 + .../AtsGoCodeGenerator.cs | 16 +- .../AtsJavaCodeGenerator.cs | 16 +- .../AtsRustCodeGenerator.cs | 19 +- .../AtsTypeScriptCodeGenerator.cs | 4 +- .../Ats/AtsCapabilityScanner.cs | 49 + .../Snapshots/AtsGeneratedAspire.verified.go | 15 + ...TwoPassScanningGeneratedAspire.verified.go | 366 +- .../AtsGeneratedAspire.verified.java | 10 + ...oPassScanningGeneratedAspire.verified.java | 218 +- .../Snapshots/AtsGeneratedAspire.verified.py | 8 + ...TwoPassScanningGeneratedAspire.verified.py | 182 +- .../Snapshots/AtsGeneratedAspire.verified.rs | 11 + ...TwoPassScanningGeneratedAspire.verified.rs | 301 + .../AtsTypeScriptCodeGeneratorTests.cs | 15 + .../Snapshots/AtsGeneratedAspire.verified.ts | 28 + ...TwoPassScanningGeneratedAspire.verified.ts | 28 + .../TestTypes/TestExtensions.cs | 13 + .../Ats/AtsCapabilityScannerTests.cs | 63 + 35 files changed, 9265 insertions(+), 99 deletions(-) create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.aspire/settings.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/.codegen-hash create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/aspire.ts create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/base.ts create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/transport.ts create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/apphost.run.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/apphost.ts create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/package-lock.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/package.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/tsconfig.json create mode 100644 src/Aspire.Hosting.Azure.AppContainers/AtsTypeMappings.cs diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.aspire/settings.json new file mode 100644 index 00000000000..66a4c4f3e5a --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.aspire/settings.json @@ -0,0 +1,8 @@ +{ + "appHostPath": "../apphost.ts", + "language": "typescript/nodejs", + "packages": { + "Aspire.Hosting.Azure.AppContainers": "", + "Aspire.Hosting.Azure.OperationalInsights": "" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/.codegen-hash b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/.codegen-hash new file mode 100644 index 00000000000..508e43e71fa --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/.codegen-hash @@ -0,0 +1 @@ +BFDBEE79E391368156C929FBA3E89038416E658D6130E0C7BE7C4AE69BF97E8D \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/aspire.ts b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/aspire.ts new file mode 100644 index 00000000000..ee93868876b --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/aspire.ts @@ -0,0 +1,5859 @@ +// aspire.ts - Capability-based Aspire SDK +// This SDK uses the ATS (Aspire Type System) capability API. +// Capabilities are endpoints like 'Aspire.Hosting/createBuilder'. +// +// GENERATED CODE - DO NOT EDIT + +import { + AspireClient as AspireClientRpc, + Handle, + MarshalledHandle, + CapabilityError, + registerCallback, + wrapIfHandle, + registerHandleWrapper +} from './transport.js'; + +import { + ResourceBuilderBase, + ReferenceExpression, + refExpr, + AspireDict, + AspireList +} from './base.js'; + +// ============================================================================ +// Handle Type Aliases (Internal - not exported to users) +// ============================================================================ + +/** Handle to AzureContainerAppEnvironmentResource */ +type AzureContainerAppEnvironmentResourceHandle = Handle<'Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.AzureContainerAppEnvironmentResource'>; + +/** Handle to AzureLogAnalyticsWorkspaceResource */ +type AzureLogAnalyticsWorkspaceResourceHandle = Handle<'Aspire.Hosting.Azure.OperationalInsights/Aspire.Hosting.Azure.AzureLogAnalyticsWorkspaceResource'>; + +/** Handle to AzureResourceInfrastructure */ +type AzureResourceInfrastructureHandle = Handle<'Aspire.Hosting.Azure/Aspire.Hosting.Azure.AzureResourceInfrastructure'>; + +/** Handle to CommandLineArgsCallbackContext */ +type CommandLineArgsCallbackContextHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext'>; + +/** Handle to ContainerResource */ +type ContainerResourceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource'>; + +/** Handle to EndpointReference */ +type EndpointReferenceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference'>; + +/** Handle to EndpointReferenceExpression */ +type EndpointReferenceExpressionHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression'>; + +/** Handle to EnvironmentCallbackContext */ +type EnvironmentCallbackContextHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext'>; + +/** Handle to ExecutableResource */ +type ExecutableResourceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource'>; + +/** Handle to ExecuteCommandContext */ +type ExecuteCommandContextHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext'>; + +/** Handle to IComputeResource */ +type IComputeResourceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IComputeResource'>; + +/** Handle to IResource */ +type IResourceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource'>; + +/** Handle to IResourceWithArgs */ +type IResourceWithArgsHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs'>; + +/** Handle to IResourceWithConnectionString */ +type IResourceWithConnectionStringHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString'>; + +/** Handle to IResourceWithEndpoints */ +type IResourceWithEndpointsHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints'>; + +/** Handle to IResourceWithEnvironment */ +type IResourceWithEnvironmentHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment'>; + +/** Handle to IResourceWithWaitSupport */ +type IResourceWithWaitSupportHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport'>; + +/** Handle to ParameterResource */ +type ParameterResourceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource'>; + +/** Handle to ProjectResource */ +type ProjectResourceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource'>; + +/** Handle to ReferenceExpression */ +type ReferenceExpressionHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression'>; + +/** Handle to ResourceLoggerService */ +type ResourceLoggerServiceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceLoggerService'>; + +/** Handle to ResourceNotificationService */ +type ResourceNotificationServiceHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceNotificationService'>; + +/** Handle to ResourceUrlsCallbackContext */ +type ResourceUrlsCallbackContextHandle = Handle<'Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext'>; + +/** Handle to DistributedApplication */ +type DistributedApplicationHandle = Handle<'Aspire.Hosting/Aspire.Hosting.DistributedApplication'>; + +/** Handle to DistributedApplicationExecutionContext */ +type DistributedApplicationExecutionContextHandle = Handle<'Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext'>; + +/** Handle to DistributedApplicationEventSubscription */ +type DistributedApplicationEventSubscriptionHandle = Handle<'Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription'>; + +/** Handle to IDistributedApplicationEventing */ +type IDistributedApplicationEventingHandle = Handle<'Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEventing'>; + +/** Handle to IDistributedApplicationBuilder */ +type IDistributedApplicationBuilderHandle = Handle<'Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder'>; + +/** Handle to IResourceWithServiceDiscovery */ +type IResourceWithServiceDiscoveryHandle = Handle<'Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery'>; + +/** Handle to Dict */ +type DictstringanyHandle = Handle<'Aspire.Hosting/Dict'>; + +/** Handle to List */ +type ListanyHandle = Handle<'Aspire.Hosting/List'>; + +/** Handle to ContainerApp */ +type ContainerAppHandle = Handle<'Azure.Provisioning.AppContainers/Azure.Provisioning.AppContainers.ContainerApp'>; + +/** Handle to ContainerAppJob */ +type ContainerAppJobHandle = Handle<'Azure.Provisioning.AppContainers/Azure.Provisioning.AppContainers.ContainerAppJob'>; + +/** Handle to IConfiguration */ +type IConfigurationHandle = Handle<'Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfiguration'>; + +/** Handle to IHostEnvironment */ +type IHostEnvironmentHandle = Handle<'Microsoft.Extensions.Hosting.Abstractions/Microsoft.Extensions.Hosting.IHostEnvironment'>; + +/** Handle to ILogger */ +type ILoggerHandle = Handle<'Microsoft.Extensions.Logging.Abstractions/Microsoft.Extensions.Logging.ILogger'>; + +/** Handle to string[] */ +type stringArrayHandle = Handle<'string[]'>; + +/** Handle to IServiceProvider */ +type IServiceProviderHandle = Handle<'System.ComponentModel/System.IServiceProvider'>; + +// ============================================================================ +// Enum Types +// ============================================================================ + +/** Enum type for ContainerLifetime */ +export enum ContainerLifetime { + Session = "Session", + Persistent = "Persistent", +} + +/** Enum type for DistributedApplicationOperation */ +export enum DistributedApplicationOperation { + Run = "Run", + Publish = "Publish", +} + +/** Enum type for EndpointProperty */ +export enum EndpointProperty { + Url = "Url", + Host = "Host", + IPV4Host = "IPV4Host", + Port = "Port", + Scheme = "Scheme", + TargetPort = "TargetPort", + HostAndPort = "HostAndPort", +} + +/** Enum type for IconVariant */ +export enum IconVariant { + Regular = "Regular", + Filled = "Filled", +} + +/** Enum type for ImagePullPolicy */ +export enum ImagePullPolicy { + Default = "Default", + Always = "Always", + Missing = "Missing", + Never = "Never", +} + +/** Enum type for ProtocolType */ +export enum ProtocolType { + IP = "IP", + IPv6HopByHopOptions = "IPv6HopByHopOptions", + Unspecified = "Unspecified", + Icmp = "Icmp", + Igmp = "Igmp", + Ggp = "Ggp", + IPv4 = "IPv4", + Tcp = "Tcp", + Pup = "Pup", + Udp = "Udp", + Idp = "Idp", + IPv6 = "IPv6", + IPv6RoutingHeader = "IPv6RoutingHeader", + IPv6FragmentHeader = "IPv6FragmentHeader", + IPSecEncapsulatingSecurityPayload = "IPSecEncapsulatingSecurityPayload", + IPSecAuthenticationHeader = "IPSecAuthenticationHeader", + IcmpV6 = "IcmpV6", + IPv6NoNextHeader = "IPv6NoNextHeader", + IPv6DestinationOptions = "IPv6DestinationOptions", + ND = "ND", + Raw = "Raw", + Ipx = "Ipx", + Spx = "Spx", + SpxII = "SpxII", + Unknown = "Unknown", +} + +/** Enum type for UrlDisplayLocation */ +export enum UrlDisplayLocation { + SummaryAndDetails = "SummaryAndDetails", + DetailsOnly = "DetailsOnly", +} + +// ============================================================================ +// DTO Interfaces +// ============================================================================ + +/** DTO interface for CommandOptions */ +export interface CommandOptions { + description?: string; + parameter?: any; + confirmationMessage?: string; + iconName?: string; + iconVariant?: IconVariant; + isHighlighted?: boolean; + updateState?: any; +} + +/** DTO interface for CreateBuilderOptions */ +export interface CreateBuilderOptions { + args?: string[]; + projectDirectory?: string; + appHostFilePath?: string; + containerRegistryOverride?: string; + disableDashboard?: boolean; + dashboardApplicationName?: string; + allowUnsecuredTransport?: boolean; + enableResourceLogging?: boolean; +} + +/** DTO interface for ExecuteCommandResult */ +export interface ExecuteCommandResult { + success?: boolean; + canceled?: boolean; + errorMessage?: string; +} + +/** DTO interface for ResourceEventDto */ +export interface ResourceEventDto { + resourceName?: string; + resourceId?: string; + state?: string; + stateStyle?: string; + healthStatus?: string; + exitCode?: number; +} + +/** DTO interface for ResourceUrlAnnotation */ +export interface ResourceUrlAnnotation { + url?: string; + displayText?: string; + endpoint?: EndpointReferenceHandle; + displayLocation?: UrlDisplayLocation; +} + +// ============================================================================ +// Options Interfaces +// ============================================================================ + +export interface AddConnectionStringOptions { + environmentVariableName?: string; +} + +export interface AddParameterOptions { + secret?: boolean; +} + +export interface GetValueAsyncOptions { + cancellationToken?: AbortSignal; +} + +export interface PublishAsConfiguredScheduledAzureContainerAppJobOptions { + configure?: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise; +} + +export interface RunOptions { + cancellationToken?: AbortSignal; +} + +export interface WaitForCompletionOptions { + exitCode?: number; +} + +export interface WithBindMountOptions { + isReadOnly?: boolean; +} + +export interface WithCommandOptions { + commandOptions?: CommandOptions; +} + +export interface WithDashboardOptions { + enable?: boolean; +} + +export interface WithDescriptionOptions { + enableMarkdown?: boolean; +} + +export interface WithEndpointOptions { + port?: number; + targetPort?: number; + scheme?: string; + name?: string; + env?: string; + isProxied?: boolean; + isExternal?: boolean; + protocol?: ProtocolType; +} + +export interface WithHttpEndpointOptions { + port?: number; + targetPort?: number; + name?: string; + env?: string; + isProxied?: boolean; +} + +export interface WithHttpHealthCheckOptions { + path?: string; + statusCode?: number; + endpointName?: string; +} + +export interface WithHttpsEndpointOptions { + port?: number; + targetPort?: number; + name?: string; + env?: string; + isProxied?: boolean; +} + +export interface WithHttpsUpgradeOptions { + upgrade?: boolean; +} + +export interface WithImageOptions { + tag?: string; +} + +export interface WithReferenceOptions { + connectionName?: string; + optional?: boolean; +} + +export interface WithUrlExpressionOptions { + displayText?: string; +} + +export interface WithUrlOptions { + displayText?: string; +} + +export interface WithVolumeOptions { + name?: string; + isReadOnly?: boolean; +} + +// ============================================================================ +// CommandLineArgsCallbackContext +// ============================================================================ + +/** + * Type class for CommandLineArgsCallbackContext. + */ +export class CommandLineArgsCallbackContext { + constructor(private _handle: CommandLineArgsCallbackContextHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Gets the Args property */ + private _args?: AspireList; + get args(): AspireList { + if (!this._args) { + this._args = new AspireList( + this._handle, + this._client, + 'Aspire.Hosting.ApplicationModel/CommandLineArgsCallbackContext.args', + 'Aspire.Hosting.ApplicationModel/CommandLineArgsCallbackContext.args' + ); + } + return this._args; + } + + /** Gets the CancellationToken property */ + cancellationToken = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/CommandLineArgsCallbackContext.cancellationToken', + { context: this._handle } + ); + }, + }; + + /** Gets the ExecutionContext property */ + executionContext = { + get: async (): Promise => { + const handle = await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/CommandLineArgsCallbackContext.executionContext', + { context: this._handle } + ); + return new DistributedApplicationExecutionContext(handle, this._client); + }, + }; + +} + +// ============================================================================ +// DistributedApplication +// ============================================================================ + +/** + * Type class for DistributedApplication. + */ +export class DistributedApplication { + constructor(private _handle: DistributedApplicationHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Runs the distributed application */ + /** @internal */ + async _runInternal(cancellationToken?: AbortSignal): Promise { + const rpcArgs: Record = { context: this._handle }; + if (cancellationToken !== undefined) rpcArgs.cancellationToken = cancellationToken; + await this._client.invokeCapability( + 'Aspire.Hosting/run', + rpcArgs + ); + return this; + } + + run(options?: RunOptions): DistributedApplicationPromise { + const cancellationToken = options?.cancellationToken; + return new DistributedApplicationPromise(this._runInternal(cancellationToken)); + } + +} + +/** + * Thenable wrapper for DistributedApplication that enables fluent chaining. + */ +export class DistributedApplicationPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: DistributedApplication) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Runs the distributed application */ + run(options?: RunOptions): DistributedApplicationPromise { + return new DistributedApplicationPromise(this._promise.then(obj => obj.run(options))); + } + +} + +// ============================================================================ +// DistributedApplicationExecutionContext +// ============================================================================ + +/** + * Type class for DistributedApplicationExecutionContext. + */ +export class DistributedApplicationExecutionContext { + constructor(private _handle: DistributedApplicationExecutionContextHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Gets the PublisherName property */ + publisherName = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting/DistributedApplicationExecutionContext.publisherName', + { context: this._handle } + ); + }, + set: async (value: string): Promise => { + await this._client.invokeCapability( + 'Aspire.Hosting/DistributedApplicationExecutionContext.setPublisherName', + { context: this._handle, value } + ); + } + }; + + /** Gets the Operation property */ + operation = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting/DistributedApplicationExecutionContext.operation', + { context: this._handle } + ); + }, + }; + + /** Gets the IsPublishMode property */ + isPublishMode = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting/DistributedApplicationExecutionContext.isPublishMode', + { context: this._handle } + ); + }, + }; + + /** Gets the IsRunMode property */ + isRunMode = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting/DistributedApplicationExecutionContext.isRunMode', + { context: this._handle } + ); + }, + }; + +} + +// ============================================================================ +// EndpointReference +// ============================================================================ + +/** + * Type class for EndpointReference. + */ +export class EndpointReference { + constructor(private _handle: EndpointReferenceHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Gets the EndpointName property */ + endpointName = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.endpointName', + { context: this._handle } + ); + }, + }; + + /** Gets the ErrorMessage property */ + errorMessage = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.errorMessage', + { context: this._handle } + ); + }, + set: async (value: string): Promise => { + await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.setErrorMessage', + { context: this._handle, value } + ); + } + }; + + /** Gets the IsAllocated property */ + isAllocated = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.isAllocated', + { context: this._handle } + ); + }, + }; + + /** Gets the Exists property */ + exists = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.exists', + { context: this._handle } + ); + }, + }; + + /** Gets the IsHttp property */ + isHttp = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.isHttp', + { context: this._handle } + ); + }, + }; + + /** Gets the IsHttps property */ + isHttps = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.isHttps', + { context: this._handle } + ); + }, + }; + + /** Gets the Port property */ + port = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.port', + { context: this._handle } + ); + }, + }; + + /** Gets the TargetPort property */ + targetPort = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.targetPort', + { context: this._handle } + ); + }, + }; + + /** Gets the Host property */ + host = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.host', + { context: this._handle } + ); + }, + }; + + /** Gets the Scheme property */ + scheme = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.scheme', + { context: this._handle } + ); + }, + }; + + /** Gets the Url property */ + url = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReference.url', + { context: this._handle } + ); + }, + }; + + /** Gets the URL of the endpoint asynchronously */ + async getValueAsync(options?: GetValueAsyncOptions): Promise { + const cancellationToken = options?.cancellationToken; + const rpcArgs: Record = { context: this._handle }; + if (cancellationToken !== undefined) rpcArgs.cancellationToken = cancellationToken; + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/getValueAsync', + rpcArgs + ); + } + +} + +/** + * Thenable wrapper for EndpointReference that enables fluent chaining. + */ +export class EndpointReferencePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: EndpointReference) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Gets the URL of the endpoint asynchronously */ + getValueAsync(options?: GetValueAsyncOptions): Promise { + return this._promise.then(obj => obj.getValueAsync(options)); + } + +} + +// ============================================================================ +// EndpointReferenceExpression +// ============================================================================ + +/** + * Type class for EndpointReferenceExpression. + */ +export class EndpointReferenceExpression { + constructor(private _handle: EndpointReferenceExpressionHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Gets the Endpoint property */ + endpoint = { + get: async (): Promise => { + const handle = await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReferenceExpression.endpoint', + { context: this._handle } + ); + return new EndpointReference(handle, this._client); + }, + }; + + /** Gets the Property property */ + property = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReferenceExpression.property', + { context: this._handle } + ); + }, + }; + + /** Gets the ValueExpression property */ + valueExpression = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EndpointReferenceExpression.valueExpression', + { context: this._handle } + ); + }, + }; + +} + +// ============================================================================ +// EnvironmentCallbackContext +// ============================================================================ + +/** + * Type class for EnvironmentCallbackContext. + */ +export class EnvironmentCallbackContext { + constructor(private _handle: EnvironmentCallbackContextHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Gets the EnvironmentVariables property */ + private _environmentVariables?: AspireDict; + get environmentVariables(): AspireDict { + if (!this._environmentVariables) { + this._environmentVariables = new AspireDict( + this._handle, + this._client, + 'Aspire.Hosting.ApplicationModel/EnvironmentCallbackContext.environmentVariables', + 'Aspire.Hosting.ApplicationModel/EnvironmentCallbackContext.environmentVariables' + ); + } + return this._environmentVariables; + } + + /** Gets the CancellationToken property */ + cancellationToken = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EnvironmentCallbackContext.cancellationToken', + { context: this._handle } + ); + }, + }; + + /** Gets the ExecutionContext property */ + executionContext = { + get: async (): Promise => { + const handle = await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/EnvironmentCallbackContext.executionContext', + { context: this._handle } + ); + return new DistributedApplicationExecutionContext(handle, this._client); + }, + }; + +} + +// ============================================================================ +// ExecuteCommandContext +// ============================================================================ + +/** + * Type class for ExecuteCommandContext. + */ +export class ExecuteCommandContext { + constructor(private _handle: ExecuteCommandContextHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Gets the ResourceName property */ + resourceName = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/ExecuteCommandContext.resourceName', + { context: this._handle } + ); + }, + set: async (value: string): Promise => { + await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/ExecuteCommandContext.setResourceName', + { context: this._handle, value } + ); + } + }; + + /** Gets the CancellationToken property */ + cancellationToken = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/ExecuteCommandContext.cancellationToken', + { context: this._handle } + ); + }, + set: async (value: AbortSignal): Promise => { + await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/ExecuteCommandContext.setCancellationToken', + { context: this._handle, value } + ); + } + }; + +} + +// ============================================================================ +// ResourceUrlsCallbackContext +// ============================================================================ + +/** + * Type class for ResourceUrlsCallbackContext. + */ +export class ResourceUrlsCallbackContext { + constructor(private _handle: ResourceUrlsCallbackContextHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Gets the Urls property */ + private _urls?: AspireList; + get urls(): AspireList { + if (!this._urls) { + this._urls = new AspireList( + this._handle, + this._client, + 'Aspire.Hosting.ApplicationModel/ResourceUrlsCallbackContext.urls', + 'Aspire.Hosting.ApplicationModel/ResourceUrlsCallbackContext.urls' + ); + } + return this._urls; + } + + /** Gets the CancellationToken property */ + cancellationToken = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/ResourceUrlsCallbackContext.cancellationToken', + { context: this._handle } + ); + }, + }; + + /** Gets the ExecutionContext property */ + executionContext = { + get: async (): Promise => { + const handle = await this._client.invokeCapability( + 'Aspire.Hosting.ApplicationModel/ResourceUrlsCallbackContext.executionContext', + { context: this._handle } + ); + return new DistributedApplicationExecutionContext(handle, this._client); + }, + }; + +} + +// ============================================================================ +// DistributedApplicationBuilder +// ============================================================================ + +/** + * Type class for DistributedApplicationBuilder. + */ +export class DistributedApplicationBuilder { + constructor(private _handle: IDistributedApplicationBuilderHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Gets the AppHostDirectory property */ + appHostDirectory = { + get: async (): Promise => { + return await this._client.invokeCapability( + 'Aspire.Hosting/IDistributedApplicationBuilder.appHostDirectory', + { context: this._handle } + ); + }, + }; + + /** Gets the Eventing property */ + eventing = { + get: async (): Promise => { + const handle = await this._client.invokeCapability( + 'Aspire.Hosting/IDistributedApplicationBuilder.eventing', + { context: this._handle } + ); + return new DistributedApplicationEventing(handle, this._client); + }, + }; + + /** Gets the ExecutionContext property */ + executionContext = { + get: async (): Promise => { + const handle = await this._client.invokeCapability( + 'Aspire.Hosting/IDistributedApplicationBuilder.executionContext', + { context: this._handle } + ); + return new DistributedApplicationExecutionContext(handle, this._client); + }, + }; + + /** Builds the distributed application */ + /** @internal */ + async _buildInternal(): Promise { + const rpcArgs: Record = { context: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/build', + rpcArgs + ); + return new DistributedApplication(result, this._client); + } + + build(): DistributedApplicationPromise { + return new DistributedApplicationPromise(this._buildInternal()); + } + + /** Adds a container resource */ + /** @internal */ + async _addContainerInternal(name: string, image: string): Promise { + const rpcArgs: Record = { builder: this._handle, name, image }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/addContainer', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + addContainer(name: string, image: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._addContainerInternal(name, image)); + } + + /** Adds an executable resource */ + /** @internal */ + async _addExecutableInternal(name: string, command: string, workingDirectory: string, args: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, name, command, workingDirectory, args }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/addExecutable', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + addExecutable(name: string, command: string, workingDirectory: string, args: string[]): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._addExecutableInternal(name, command, workingDirectory, args)); + } + + /** Adds a parameter resource */ + /** @internal */ + async _addParameterInternal(name: string, secret?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + if (secret !== undefined) rpcArgs.secret = secret; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/addParameter', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + addParameter(name: string, options?: AddParameterOptions): ParameterResourcePromise { + const secret = options?.secret; + return new ParameterResourcePromise(this._addParameterInternal(name, secret)); + } + + /** Adds a connection string resource */ + /** @internal */ + async _addConnectionStringInternal(name: string, environmentVariableName?: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + if (environmentVariableName !== undefined) rpcArgs.environmentVariableName = environmentVariableName; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/addConnectionString', + rpcArgs + ); + return new ResourceWithConnectionString(result, this._client); + } + + addConnectionString(name: string, options?: AddConnectionStringOptions): ResourceWithConnectionStringPromise { + const environmentVariableName = options?.environmentVariableName; + return new ResourceWithConnectionStringPromise(this._addConnectionStringInternal(name, environmentVariableName)); + } + + /** Adds a .NET project resource */ + /** @internal */ + async _addProjectInternal(name: string, projectPath: string, launchProfileName: string): Promise { + const rpcArgs: Record = { builder: this._handle, name, projectPath, launchProfileName }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/addProject', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + addProject(name: string, projectPath: string, launchProfileName: string): ProjectResourcePromise { + return new ProjectResourcePromise(this._addProjectInternal(name, projectPath, launchProfileName)); + } + + /** Adds an Azure Container App Environment resource */ + /** @internal */ + async _addAzureContainerAppEnvironmentInternal(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/addAzureContainerAppEnvironment', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + addAzureContainerAppEnvironment(name: string): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._addAzureContainerAppEnvironmentInternal(name)); + } + + /** Adds an Azure Log Analytics Workspace resource */ + /** @internal */ + async _addAzureLogAnalyticsWorkspaceInternal(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.OperationalInsights/addAzureLogAnalyticsWorkspace', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + addAzureLogAnalyticsWorkspace(name: string): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._addAzureLogAnalyticsWorkspaceInternal(name)); + } + +} + +/** + * Thenable wrapper for DistributedApplicationBuilder that enables fluent chaining. + */ +export class DistributedApplicationBuilderPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: DistributedApplicationBuilder) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Builds the distributed application */ + build(): DistributedApplicationPromise { + return new DistributedApplicationPromise(this._promise.then(obj => obj.build())); + } + + /** Adds a container resource */ + addContainer(name: string, image: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.addContainer(name, image))); + } + + /** Adds an executable resource */ + addExecutable(name: string, command: string, workingDirectory: string, args: string[]): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.addExecutable(name, command, workingDirectory, args))); + } + + /** Adds a parameter resource */ + addParameter(name: string, options?: AddParameterOptions): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.addParameter(name, options))); + } + + /** Adds a connection string resource */ + addConnectionString(name: string, options?: AddConnectionStringOptions): ResourceWithConnectionStringPromise { + return new ResourceWithConnectionStringPromise(this._promise.then(obj => obj.addConnectionString(name, options))); + } + + /** Adds a .NET project resource */ + addProject(name: string, projectPath: string, launchProfileName: string): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.addProject(name, projectPath, launchProfileName))); + } + + /** Adds an Azure Container App Environment resource */ + addAzureContainerAppEnvironment(name: string): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.addAzureContainerAppEnvironment(name))); + } + + /** Adds an Azure Log Analytics Workspace resource */ + addAzureLogAnalyticsWorkspace(name: string): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.addAzureLogAnalyticsWorkspace(name))); + } + +} + +// ============================================================================ +// DistributedApplicationEventing +// ============================================================================ + +/** + * Type class for DistributedApplicationEventing. + */ +export class DistributedApplicationEventing { + constructor(private _handle: IDistributedApplicationEventingHandle, private _client: AspireClientRpc) {} + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { return this._handle.toJSON(); } + + /** Invokes the Unsubscribe method */ + /** @internal */ + async _unsubscribeInternal(subscription: DistributedApplicationEventSubscriptionHandle): Promise { + const rpcArgs: Record = { context: this._handle, subscription }; + await this._client.invokeCapability( + 'Aspire.Hosting.Eventing/IDistributedApplicationEventing.unsubscribe', + rpcArgs + ); + return this; + } + + unsubscribe(subscription: DistributedApplicationEventSubscriptionHandle): DistributedApplicationEventingPromise { + return new DistributedApplicationEventingPromise(this._unsubscribeInternal(subscription)); + } + +} + +/** + * Thenable wrapper for DistributedApplicationEventing that enables fluent chaining. + */ +export class DistributedApplicationEventingPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: DistributedApplicationEventing) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Invokes the Unsubscribe method */ + unsubscribe(subscription: DistributedApplicationEventSubscriptionHandle): DistributedApplicationEventingPromise { + return new DistributedApplicationEventingPromise(this._promise.then(obj => obj.unsubscribe(subscription))); + } + +} + +// ============================================================================ +// AzureContainerAppEnvironmentResource +// ============================================================================ + +export class AzureContainerAppEnvironmentResource extends ResourceBuilderBase { + constructor(handle: AzureContainerAppEnvironmentResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; + const obj = new ResourceUrlsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallback', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withUrlsCallbackInternal(callback)); + } + + /** @internal */ + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; + const arg = new ResourceUrlsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallbackAsync', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withUrlInternal(url: string, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrl', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): AzureContainerAppEnvironmentResourcePromise { + const displayText = options?.displayText; + return new AzureContainerAppEnvironmentResourcePromise(this._withUrlInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlExpression', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): AzureContainerAppEnvironmentResourcePromise { + const displayText = options?.displayText; + return new AzureContainerAppEnvironmentResourcePromise(this._withUrlExpressionInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpoint', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + } + + /** @internal */ + private async _withExplicitStartInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExplicitStart', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withExplicitStartInternal()); + } + + /** @internal */ + private async _withHealthCheckInternal(key: string): Promise { + const rpcArgs: Record = { builder: this._handle, key }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHealthCheck', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withHealthCheckInternal(key)); + } + + /** @internal */ + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + const executeCommandId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; + const arg = new ExecuteCommandContext(argHandle, this._client); + return await executeCommand(arg); + }); + const rpcArgs: Record = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; + if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withCommand', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): AzureContainerAppEnvironmentResourcePromise { + const commandOptions = options?.commandOptions; + return new AzureContainerAppEnvironmentResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + } + + /** @internal */ + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, parent }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withParentRelationship', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withParentRelationshipInternal(parent)); + } + + /** Gets the resource name */ + async getResourceName(): Promise { + const rpcArgs: Record = { resource: this._handle }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getResourceName', + rpcArgs + ); + } + + /** @internal */ + private async _withAzdResourceNamingInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/withAzdResourceNaming', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Configures resources to use azd naming conventions */ + withAzdResourceNaming(): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withAzdResourceNamingInternal()); + } + + /** @internal */ + private async _withCompactResourceNamingInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/withCompactResourceNaming', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Configures resources to use compact naming for length-constrained Azure resources */ + withCompactResourceNaming(): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withCompactResourceNamingInternal()); + } + + /** @internal */ + private async _withDashboardInternal(enable?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (enable !== undefined) rpcArgs.enable = enable; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/withDashboard', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Configures whether the Aspire dashboard is included in the container app environment */ + withDashboard(options?: WithDashboardOptions): AzureContainerAppEnvironmentResourcePromise { + const enable = options?.enable; + return new AzureContainerAppEnvironmentResourcePromise(this._withDashboardInternal(enable)); + } + + /** @internal */ + private async _withHttpsUpgradeInternal(upgrade?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (upgrade !== undefined) rpcArgs.upgrade = upgrade; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/withHttpsUpgrade', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Configures whether HTTP endpoints are upgraded to HTTPS */ + withHttpsUpgrade(options?: WithHttpsUpgradeOptions): AzureContainerAppEnvironmentResourcePromise { + const upgrade = options?.upgrade; + return new AzureContainerAppEnvironmentResourcePromise(this._withHttpsUpgradeInternal(upgrade)); + } + + /** @internal */ + private async _withAzureLogAnalyticsWorkspaceInternal(workspaceBuilder: AzureLogAnalyticsWorkspaceResource): Promise { + const rpcArgs: Record = { builder: this._handle, workspaceBuilder }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/withAzureLogAnalyticsWorkspace', + rpcArgs + ); + return new AzureContainerAppEnvironmentResource(result, this._client); + } + + /** Configures the container app environment to use a specific Log Analytics Workspace */ + withAzureLogAnalyticsWorkspace(workspaceBuilder: AzureLogAnalyticsWorkspaceResource): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._withAzureLogAnalyticsWorkspaceInternal(workspaceBuilder)); + } + +} + +/** + * Thenable wrapper for AzureContainerAppEnvironmentResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class AzureContainerAppEnvironmentResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: AzureContainerAppEnvironmentResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withUrlsCallback(callback))); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withUrlsCallbackAsync(callback))); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withUrl(url, options))); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withUrlExpression(url, options))); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withUrlForEndpoint(endpointName, callback))); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withExplicitStart())); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withHealthCheck(key))); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withParentRelationship(parent))); + } + + /** Gets the resource name */ + getResourceName(): Promise { + return this._promise.then(obj => obj.getResourceName()); + } + + /** Configures resources to use azd naming conventions */ + withAzdResourceNaming(): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withAzdResourceNaming())); + } + + /** Configures resources to use compact naming for length-constrained Azure resources */ + withCompactResourceNaming(): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withCompactResourceNaming())); + } + + /** Configures whether the Aspire dashboard is included in the container app environment */ + withDashboard(options?: WithDashboardOptions): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withDashboard(options))); + } + + /** Configures whether HTTP endpoints are upgraded to HTTPS */ + withHttpsUpgrade(options?: WithHttpsUpgradeOptions): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withHttpsUpgrade(options))); + } + + /** Configures the container app environment to use a specific Log Analytics Workspace */ + withAzureLogAnalyticsWorkspace(workspaceBuilder: AzureLogAnalyticsWorkspaceResource): AzureContainerAppEnvironmentResourcePromise { + return new AzureContainerAppEnvironmentResourcePromise(this._promise.then(obj => obj.withAzureLogAnalyticsWorkspace(workspaceBuilder))); + } + +} + +// ============================================================================ +// AzureLogAnalyticsWorkspaceResource +// ============================================================================ + +export class AzureLogAnalyticsWorkspaceResource extends ResourceBuilderBase { + constructor(handle: AzureLogAnalyticsWorkspaceResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; + const obj = new ResourceUrlsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallback', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withUrlsCallbackInternal(callback)); + } + + /** @internal */ + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; + const arg = new ResourceUrlsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallbackAsync', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withUrlInternal(url: string, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrl', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): AzureLogAnalyticsWorkspaceResourcePromise { + const displayText = options?.displayText; + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withUrlInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlExpression', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): AzureLogAnalyticsWorkspaceResourcePromise { + const displayText = options?.displayText; + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withUrlExpressionInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpoint', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + } + + /** @internal */ + private async _withExplicitStartInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExplicitStart', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withExplicitStartInternal()); + } + + /** @internal */ + private async _withHealthCheckInternal(key: string): Promise { + const rpcArgs: Record = { builder: this._handle, key }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHealthCheck', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withHealthCheckInternal(key)); + } + + /** @internal */ + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + const executeCommandId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; + const arg = new ExecuteCommandContext(argHandle, this._client); + return await executeCommand(arg); + }); + const rpcArgs: Record = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; + if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withCommand', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): AzureLogAnalyticsWorkspaceResourcePromise { + const commandOptions = options?.commandOptions; + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + } + + /** @internal */ + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, parent }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withParentRelationship', + rpcArgs + ); + return new AzureLogAnalyticsWorkspaceResource(result, this._client); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._withParentRelationshipInternal(parent)); + } + + /** Gets the resource name */ + async getResourceName(): Promise { + const rpcArgs: Record = { resource: this._handle }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getResourceName', + rpcArgs + ); + } + +} + +/** + * Thenable wrapper for AzureLogAnalyticsWorkspaceResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class AzureLogAnalyticsWorkspaceResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: AzureLogAnalyticsWorkspaceResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withUrlsCallback(callback))); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withUrlsCallbackAsync(callback))); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withUrl(url, options))); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withUrlExpression(url, options))); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withUrlForEndpoint(endpointName, callback))); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withExplicitStart())); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withHealthCheck(key))); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): AzureLogAnalyticsWorkspaceResourcePromise { + return new AzureLogAnalyticsWorkspaceResourcePromise(this._promise.then(obj => obj.withParentRelationship(parent))); + } + + /** Gets the resource name */ + getResourceName(): Promise { + return this._promise.then(obj => obj.getResourceName()); + } + +} + +// ============================================================================ +// ContainerResource +// ============================================================================ + +export class ContainerResource extends ResourceBuilderBase { + constructor(handle: ContainerResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withBindMountInternal(source: string, target: string, isReadOnly?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, source, target }; + if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBindMount', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a bind mount */ + withBindMount(source: string, target: string, options?: WithBindMountOptions): ContainerResourcePromise { + const isReadOnly = options?.isReadOnly; + return new ContainerResourcePromise(this._withBindMountInternal(source, target, isReadOnly)); + } + + /** @internal */ + private async _withEntrypointInternal(entrypoint: string): Promise { + const rpcArgs: Record = { builder: this._handle, entrypoint }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEntrypoint', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets the container entrypoint */ + withEntrypoint(entrypoint: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._withEntrypointInternal(entrypoint)); + } + + /** @internal */ + private async _withImageTagInternal(tag: string): Promise { + const rpcArgs: Record = { builder: this._handle, tag }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withImageTag', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets the container image tag */ + withImageTag(tag: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._withImageTagInternal(tag)); + } + + /** @internal */ + private async _withImageRegistryInternal(registry: string): Promise { + const rpcArgs: Record = { builder: this._handle, registry }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withImageRegistry', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets the container image registry */ + withImageRegistry(registry: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._withImageRegistryInternal(registry)); + } + + /** @internal */ + private async _withImageInternal(image: string, tag?: string): Promise { + const rpcArgs: Record = { builder: this._handle, image }; + if (tag !== undefined) rpcArgs.tag = tag; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withImage', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets the container image */ + withImage(image: string, options?: WithImageOptions): ContainerResourcePromise { + const tag = options?.tag; + return new ContainerResourcePromise(this._withImageInternal(image, tag)); + } + + /** @internal */ + private async _withContainerRuntimeArgsInternal(args: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, args }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withContainerRuntimeArgs', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds runtime arguments for the container */ + withContainerRuntimeArgs(args: string[]): ContainerResourcePromise { + return new ContainerResourcePromise(this._withContainerRuntimeArgsInternal(args)); + } + + /** @internal */ + private async _withLifetimeInternal(lifetime: ContainerLifetime): Promise { + const rpcArgs: Record = { builder: this._handle, lifetime }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withLifetime', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets the lifetime behavior of the container resource */ + withLifetime(lifetime: ContainerLifetime): ContainerResourcePromise { + return new ContainerResourcePromise(this._withLifetimeInternal(lifetime)); + } + + /** @internal */ + private async _withImagePullPolicyInternal(pullPolicy: ImagePullPolicy): Promise { + const rpcArgs: Record = { builder: this._handle, pullPolicy }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withImagePullPolicy', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets the container image pull policy */ + withImagePullPolicy(pullPolicy: ImagePullPolicy): ContainerResourcePromise { + return new ContainerResourcePromise(this._withImagePullPolicyInternal(pullPolicy)); + } + + /** @internal */ + private async _withContainerNameInternal(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withContainerName', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets the container name */ + withContainerName(name: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._withContainerNameInternal(name)); + } + + /** @internal */ + private async _withEnvironmentInternal(name: string, value: string): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironment', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._withEnvironmentInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentExpressionInternal(name: string, value: ReferenceExpression): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentExpression', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): ContainerResourcePromise { + return new ContainerResourcePromise(this._withEnvironmentExpressionInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentCallbackInternal(callback: (obj: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as EnvironmentCallbackContextHandle; + const obj = new EnvironmentCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallback', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._withEnvironmentCallbackInternal(callback)); + } + + /** @internal */ + private async _withEnvironmentCallbackAsyncInternal(callback: (arg: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EnvironmentCallbackContextHandle; + const arg = new EnvironmentCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallbackAsync', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._withEnvironmentCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withArgsInternal(args: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, args }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgs', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds arguments */ + withArgs(args: string[]): ContainerResourcePromise { + return new ContainerResourcePromise(this._withArgsInternal(args)); + } + + /** @internal */ + private async _withArgsCallbackInternal(callback: (obj: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as CommandLineArgsCallbackContextHandle; + const obj = new CommandLineArgsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallback', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._withArgsCallbackInternal(callback)); + } + + /** @internal */ + private async _withArgsCallbackAsyncInternal(callback: (arg: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as CommandLineArgsCallbackContextHandle; + const arg = new CommandLineArgsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallbackAsync', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._withArgsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withReferenceInternal(source: ResourceBuilderBase, connectionName?: string, optional?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + if (connectionName !== undefined) rpcArgs.connectionName = connectionName; + if (optional !== undefined) rpcArgs.optional = optional; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withReference', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): ContainerResourcePromise { + const connectionName = options?.connectionName; + const optional = options?.optional; + return new ContainerResourcePromise(this._withReferenceInternal(source, connectionName, optional)); + } + + /** @internal */ + private async _withServiceReferenceInternal(source: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withServiceReference', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): ContainerResourcePromise { + return new ContainerResourcePromise(this._withServiceReferenceInternal(source)); + } + + /** @internal */ + private async _withEndpointInternal(port?: number, targetPort?: number, scheme?: string, name?: string, env?: string, isProxied?: boolean, isExternal?: boolean, protocol?: ProtocolType): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (scheme !== undefined) rpcArgs.scheme = scheme; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + if (isExternal !== undefined) rpcArgs.isExternal = isExternal; + if (protocol !== undefined) rpcArgs.protocol = protocol; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEndpoint', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): ContainerResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const scheme = options?.scheme; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + const isExternal = options?.isExternal; + const protocol = options?.protocol; + return new ContainerResourcePromise(this._withEndpointInternal(port, targetPort, scheme, name, env, isProxied, isExternal, protocol)); + } + + /** @internal */ + private async _withHttpEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpEndpoint', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): ContainerResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new ContainerResourcePromise(this._withHttpEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withHttpsEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpsEndpoint', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): ContainerResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new ContainerResourcePromise(this._withHttpsEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withExternalHttpEndpointsInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExternalHttpEndpoints', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): ContainerResourcePromise { + return new ContainerResourcePromise(this._withExternalHttpEndpointsInternal()); + } + + /** Gets an endpoint reference */ + async getEndpoint(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getEndpoint', + rpcArgs + ); + } + + /** @internal */ + private async _asHttp2ServiceInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/asHttp2Service', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): ContainerResourcePromise { + return new ContainerResourcePromise(this._asHttp2ServiceInternal()); + } + + /** @internal */ + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; + const obj = new ResourceUrlsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallback', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._withUrlsCallbackInternal(callback)); + } + + /** @internal */ + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; + const arg = new ResourceUrlsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallbackAsync', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withUrlInternal(url: string, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrl', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ContainerResourcePromise { + const displayText = options?.displayText; + return new ContainerResourcePromise(this._withUrlInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlExpression', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ContainerResourcePromise { + const displayText = options?.displayText; + return new ContainerResourcePromise(this._withUrlExpressionInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpoint', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + } + + /** @internal */ + private async _withUrlForEndpointFactoryInternal(endpointName: string, callback: (arg: EndpointReference) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EndpointReferenceHandle; + const arg = new EndpointReference(argHandle, this._client); + return await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpointFactory', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._withUrlForEndpointFactoryInternal(endpointName, callback)); + } + + /** @internal */ + private async _waitForInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitFor', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): ContainerResourcePromise { + return new ContainerResourcePromise(this._waitForInternal(dependency)); + } + + /** @internal */ + private async _withExplicitStartInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExplicitStart', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ContainerResourcePromise { + return new ContainerResourcePromise(this._withExplicitStartInternal()); + } + + /** @internal */ + private async _waitForCompletionInternal(dependency: ResourceBuilderBase, exitCode?: number): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + if (exitCode !== undefined) rpcArgs.exitCode = exitCode; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitForCompletion', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): ContainerResourcePromise { + const exitCode = options?.exitCode; + return new ContainerResourcePromise(this._waitForCompletionInternal(dependency, exitCode)); + } + + /** @internal */ + private async _withHealthCheckInternal(key: string): Promise { + const rpcArgs: Record = { builder: this._handle, key }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHealthCheck', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._withHealthCheckInternal(key)); + } + + /** @internal */ + private async _withHttpHealthCheckInternal(path?: string, statusCode?: number, endpointName?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (path !== undefined) rpcArgs.path = path; + if (statusCode !== undefined) rpcArgs.statusCode = statusCode; + if (endpointName !== undefined) rpcArgs.endpointName = endpointName; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpHealthCheck', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): ContainerResourcePromise { + const path = options?.path; + const statusCode = options?.statusCode; + const endpointName = options?.endpointName; + return new ContainerResourcePromise(this._withHttpHealthCheckInternal(path, statusCode, endpointName)); + } + + /** @internal */ + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + const executeCommandId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; + const arg = new ExecuteCommandContext(argHandle, this._client); + return await executeCommand(arg); + }); + const rpcArgs: Record = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; + if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withCommand', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ContainerResourcePromise { + const commandOptions = options?.commandOptions; + return new ContainerResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + } + + /** @internal */ + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, parent }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withParentRelationship', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ContainerResourcePromise { + return new ContainerResourcePromise(this._withParentRelationshipInternal(parent)); + } + + /** @internal */ + private async _withVolumeInternal(target: string, name?: string, isReadOnly?: boolean): Promise { + const rpcArgs: Record = { resource: this._handle, target }; + if (name !== undefined) rpcArgs.name = name; + if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withVolume', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds a volume */ + withVolume(target: string, options?: WithVolumeOptions): ContainerResourcePromise { + const name = options?.name; + const isReadOnly = options?.isReadOnly; + return new ContainerResourcePromise(this._withVolumeInternal(target, name, isReadOnly)); + } + + /** Gets the resource name */ + async getResourceName(): Promise { + const rpcArgs: Record = { resource: this._handle }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getResourceName', + rpcArgs + ); + } + + /** @internal */ + private async _publishAsAzureContainerAppInternal(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): Promise { + const configureId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppHandle; + await configure(arg1, arg2); + }); + const rpcArgs: Record = { container: this._handle, configure: configureId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishContainerAsAzureContainerApp', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Configures the container resource to be published as an Azure Container App */ + publishAsAzureContainerApp(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._publishAsAzureContainerAppInternal(configure)); + } + + /** @internal */ + private async _publishAsConfiguredAzureContainerAppJobInternal(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): Promise { + const configureId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppJobHandle; + await configure(arg1, arg2); + }); + const rpcArgs: Record = { resource: this._handle, configure: configureId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsConfiguredAzureContainerAppJob', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Configures the compute resource as an Azure Container App Job with custom configuration */ + publishAsConfiguredAzureContainerAppJob(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._publishAsConfiguredAzureContainerAppJobInternal(configure)); + } + + /** @internal */ + private async _publishAsAzureContainerAppJobInternal(): Promise { + const rpcArgs: Record = { resource: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsAzureContainerAppJob', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Configures the compute resource as a manually triggered Azure Container App Job */ + publishAsAzureContainerAppJob(): ContainerResourcePromise { + return new ContainerResourcePromise(this._publishAsAzureContainerAppJobInternal()); + } + + /** @internal */ + private async _publishAsConfiguredScheduledAzureContainerAppJobInternal(cronExpression: string, configure?: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): Promise { + const configureId = configure ? registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppJobHandle; + await configure(arg1, arg2); + }) : undefined; + const rpcArgs: Record = { resource: this._handle, cronExpression }; + if (configure !== undefined) rpcArgs.configure = configureId; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsConfiguredScheduledAzureContainerAppJob', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Configures the compute resource as a scheduled Azure Container App Job with custom configuration */ + publishAsConfiguredScheduledAzureContainerAppJob(cronExpression: string, options?: PublishAsConfiguredScheduledAzureContainerAppJobOptions): ContainerResourcePromise { + const configure = options?.configure; + return new ContainerResourcePromise(this._publishAsConfiguredScheduledAzureContainerAppJobInternal(cronExpression, configure)); + } + + /** @internal */ + private async _publishAsScheduledAzureContainerAppJobInternal(cronExpression: string): Promise { + const rpcArgs: Record = { resource: this._handle, cronExpression }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsScheduledAzureContainerAppJob', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Configures the compute resource as a scheduled Azure Container App Job */ + publishAsScheduledAzureContainerAppJob(cronExpression: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._publishAsScheduledAzureContainerAppJobInternal(cronExpression)); + } + +} + +/** + * Thenable wrapper for ContainerResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ContainerResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ContainerResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Adds a bind mount */ + withBindMount(source: string, target: string, options?: WithBindMountOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withBindMount(source, target, options))); + } + + /** Sets the container entrypoint */ + withEntrypoint(entrypoint: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withEntrypoint(entrypoint))); + } + + /** Sets the container image tag */ + withImageTag(tag: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withImageTag(tag))); + } + + /** Sets the container image registry */ + withImageRegistry(registry: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withImageRegistry(registry))); + } + + /** Sets the container image */ + withImage(image: string, options?: WithImageOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withImage(image, options))); + } + + /** Adds runtime arguments for the container */ + withContainerRuntimeArgs(args: string[]): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withContainerRuntimeArgs(args))); + } + + /** Sets the lifetime behavior of the container resource */ + withLifetime(lifetime: ContainerLifetime): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withLifetime(lifetime))); + } + + /** Sets the container image pull policy */ + withImagePullPolicy(pullPolicy: ImagePullPolicy): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withImagePullPolicy(pullPolicy))); + } + + /** Sets the container name */ + withContainerName(name: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withContainerName(name))); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withEnvironment(name, value))); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withEnvironmentExpression(name, value))); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withEnvironmentCallback(callback))); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withEnvironmentCallbackAsync(callback))); + } + + /** Adds arguments */ + withArgs(args: string[]): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withArgs(args))); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withArgsCallback(callback))); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withArgsCallbackAsync(callback))); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withReference(source, options))); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withServiceReference(source))); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withEndpoint(options))); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withHttpEndpoint(options))); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withHttpsEndpoint(options))); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withExternalHttpEndpoints())); + } + + /** Gets an endpoint reference */ + getEndpoint(name: string): Promise { + return this._promise.then(obj => obj.getEndpoint(name)); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.asHttp2Service())); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withUrlsCallback(callback))); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withUrlsCallbackAsync(callback))); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withUrl(url, options))); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withUrlExpression(url, options))); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withUrlForEndpoint(endpointName, callback))); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withUrlForEndpointFactory(endpointName, callback))); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.waitFor(dependency))); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withExplicitStart())); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.waitForCompletion(dependency, options))); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withHealthCheck(key))); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withHttpHealthCheck(options))); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withParentRelationship(parent))); + } + + /** Adds a volume */ + withVolume(target: string, options?: WithVolumeOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withVolume(target, options))); + } + + /** Gets the resource name */ + getResourceName(): Promise { + return this._promise.then(obj => obj.getResourceName()); + } + + /** Configures the container resource to be published as an Azure Container App */ + publishAsAzureContainerApp(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.publishAsAzureContainerApp(configure))); + } + + /** Configures the compute resource as an Azure Container App Job with custom configuration */ + publishAsConfiguredAzureContainerAppJob(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.publishAsConfiguredAzureContainerAppJob(configure))); + } + + /** Configures the compute resource as a manually triggered Azure Container App Job */ + publishAsAzureContainerAppJob(): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.publishAsAzureContainerAppJob())); + } + + /** Configures the compute resource as a scheduled Azure Container App Job with custom configuration */ + publishAsConfiguredScheduledAzureContainerAppJob(cronExpression: string, options?: PublishAsConfiguredScheduledAzureContainerAppJobOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.publishAsConfiguredScheduledAzureContainerAppJob(cronExpression, options))); + } + + /** Configures the compute resource as a scheduled Azure Container App Job */ + publishAsScheduledAzureContainerAppJob(cronExpression: string): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.publishAsScheduledAzureContainerAppJob(cronExpression))); + } + +} + +// ============================================================================ +// ExecutableResource +// ============================================================================ + +export class ExecutableResource extends ResourceBuilderBase { + constructor(handle: ExecutableResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withExecutableCommandInternal(command: string): Promise { + const rpcArgs: Record = { builder: this._handle, command }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExecutableCommand', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Sets the executable command */ + withExecutableCommand(command: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withExecutableCommandInternal(command)); + } + + /** @internal */ + private async _withWorkingDirectoryInternal(workingDirectory: string): Promise { + const rpcArgs: Record = { builder: this._handle, workingDirectory }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withWorkingDirectory', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Sets the executable working directory */ + withWorkingDirectory(workingDirectory: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withWorkingDirectoryInternal(workingDirectory)); + } + + /** @internal */ + private async _withEnvironmentInternal(name: string, value: string): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironment', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withEnvironmentInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentExpressionInternal(name: string, value: ReferenceExpression): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentExpression', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withEnvironmentExpressionInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentCallbackInternal(callback: (obj: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as EnvironmentCallbackContextHandle; + const obj = new EnvironmentCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallback', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withEnvironmentCallbackInternal(callback)); + } + + /** @internal */ + private async _withEnvironmentCallbackAsyncInternal(callback: (arg: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EnvironmentCallbackContextHandle; + const arg = new EnvironmentCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallbackAsync', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withEnvironmentCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withArgsInternal(args: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, args }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgs', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds arguments */ + withArgs(args: string[]): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withArgsInternal(args)); + } + + /** @internal */ + private async _withArgsCallbackInternal(callback: (obj: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as CommandLineArgsCallbackContextHandle; + const obj = new CommandLineArgsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallback', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withArgsCallbackInternal(callback)); + } + + /** @internal */ + private async _withArgsCallbackAsyncInternal(callback: (arg: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as CommandLineArgsCallbackContextHandle; + const arg = new CommandLineArgsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallbackAsync', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withArgsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withReferenceInternal(source: ResourceBuilderBase, connectionName?: string, optional?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + if (connectionName !== undefined) rpcArgs.connectionName = connectionName; + if (optional !== undefined) rpcArgs.optional = optional; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withReference', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): ExecutableResourcePromise { + const connectionName = options?.connectionName; + const optional = options?.optional; + return new ExecutableResourcePromise(this._withReferenceInternal(source, connectionName, optional)); + } + + /** @internal */ + private async _withServiceReferenceInternal(source: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withServiceReference', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withServiceReferenceInternal(source)); + } + + /** @internal */ + private async _withEndpointInternal(port?: number, targetPort?: number, scheme?: string, name?: string, env?: string, isProxied?: boolean, isExternal?: boolean, protocol?: ProtocolType): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (scheme !== undefined) rpcArgs.scheme = scheme; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + if (isExternal !== undefined) rpcArgs.isExternal = isExternal; + if (protocol !== undefined) rpcArgs.protocol = protocol; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEndpoint', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): ExecutableResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const scheme = options?.scheme; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + const isExternal = options?.isExternal; + const protocol = options?.protocol; + return new ExecutableResourcePromise(this._withEndpointInternal(port, targetPort, scheme, name, env, isProxied, isExternal, protocol)); + } + + /** @internal */ + private async _withHttpEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpEndpoint', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): ExecutableResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new ExecutableResourcePromise(this._withHttpEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withHttpsEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpsEndpoint', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): ExecutableResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new ExecutableResourcePromise(this._withHttpsEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withExternalHttpEndpointsInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExternalHttpEndpoints', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withExternalHttpEndpointsInternal()); + } + + /** Gets an endpoint reference */ + async getEndpoint(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getEndpoint', + rpcArgs + ); + } + + /** @internal */ + private async _asHttp2ServiceInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/asHttp2Service', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._asHttp2ServiceInternal()); + } + + /** @internal */ + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; + const obj = new ResourceUrlsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallback', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withUrlsCallbackInternal(callback)); + } + + /** @internal */ + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; + const arg = new ResourceUrlsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallbackAsync', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withUrlInternal(url: string, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrl', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ExecutableResourcePromise { + const displayText = options?.displayText; + return new ExecutableResourcePromise(this._withUrlInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlExpression', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ExecutableResourcePromise { + const displayText = options?.displayText; + return new ExecutableResourcePromise(this._withUrlExpressionInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpoint', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + } + + /** @internal */ + private async _withUrlForEndpointFactoryInternal(endpointName: string, callback: (arg: EndpointReference) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EndpointReferenceHandle; + const arg = new EndpointReference(argHandle, this._client); + return await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpointFactory', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withUrlForEndpointFactoryInternal(endpointName, callback)); + } + + /** @internal */ + private async _waitForInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitFor', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._waitForInternal(dependency)); + } + + /** @internal */ + private async _withExplicitStartInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExplicitStart', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withExplicitStartInternal()); + } + + /** @internal */ + private async _waitForCompletionInternal(dependency: ResourceBuilderBase, exitCode?: number): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + if (exitCode !== undefined) rpcArgs.exitCode = exitCode; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitForCompletion', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): ExecutableResourcePromise { + const exitCode = options?.exitCode; + return new ExecutableResourcePromise(this._waitForCompletionInternal(dependency, exitCode)); + } + + /** @internal */ + private async _withHealthCheckInternal(key: string): Promise { + const rpcArgs: Record = { builder: this._handle, key }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHealthCheck', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withHealthCheckInternal(key)); + } + + /** @internal */ + private async _withHttpHealthCheckInternal(path?: string, statusCode?: number, endpointName?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (path !== undefined) rpcArgs.path = path; + if (statusCode !== undefined) rpcArgs.statusCode = statusCode; + if (endpointName !== undefined) rpcArgs.endpointName = endpointName; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpHealthCheck', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): ExecutableResourcePromise { + const path = options?.path; + const statusCode = options?.statusCode; + const endpointName = options?.endpointName; + return new ExecutableResourcePromise(this._withHttpHealthCheckInternal(path, statusCode, endpointName)); + } + + /** @internal */ + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + const executeCommandId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; + const arg = new ExecuteCommandContext(argHandle, this._client); + return await executeCommand(arg); + }); + const rpcArgs: Record = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; + if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withCommand', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ExecutableResourcePromise { + const commandOptions = options?.commandOptions; + return new ExecutableResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + } + + /** @internal */ + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, parent }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withParentRelationship', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withParentRelationshipInternal(parent)); + } + + /** Gets the resource name */ + async getResourceName(): Promise { + const rpcArgs: Record = { resource: this._handle }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getResourceName', + rpcArgs + ); + } + + /** @internal */ + private async _publishAsAzureContainerAppInternal(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): Promise { + const configureId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppHandle; + await configure(arg1, arg2); + }); + const rpcArgs: Record = { executable: this._handle, configure: configureId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishExecutableAsAzureContainerApp', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Configures the executable resource to be published as an Azure Container App */ + publishAsAzureContainerApp(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._publishAsAzureContainerAppInternal(configure)); + } + + /** @internal */ + private async _publishAsConfiguredAzureContainerAppJobInternal(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): Promise { + const configureId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppJobHandle; + await configure(arg1, arg2); + }); + const rpcArgs: Record = { resource: this._handle, configure: configureId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsConfiguredAzureContainerAppJob', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Configures the compute resource as an Azure Container App Job with custom configuration */ + publishAsConfiguredAzureContainerAppJob(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._publishAsConfiguredAzureContainerAppJobInternal(configure)); + } + + /** @internal */ + private async _publishAsAzureContainerAppJobInternal(): Promise { + const rpcArgs: Record = { resource: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsAzureContainerAppJob', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Configures the compute resource as a manually triggered Azure Container App Job */ + publishAsAzureContainerAppJob(): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._publishAsAzureContainerAppJobInternal()); + } + + /** @internal */ + private async _publishAsConfiguredScheduledAzureContainerAppJobInternal(cronExpression: string, configure?: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): Promise { + const configureId = configure ? registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppJobHandle; + await configure(arg1, arg2); + }) : undefined; + const rpcArgs: Record = { resource: this._handle, cronExpression }; + if (configure !== undefined) rpcArgs.configure = configureId; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsConfiguredScheduledAzureContainerAppJob', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Configures the compute resource as a scheduled Azure Container App Job with custom configuration */ + publishAsConfiguredScheduledAzureContainerAppJob(cronExpression: string, options?: PublishAsConfiguredScheduledAzureContainerAppJobOptions): ExecutableResourcePromise { + const configure = options?.configure; + return new ExecutableResourcePromise(this._publishAsConfiguredScheduledAzureContainerAppJobInternal(cronExpression, configure)); + } + + /** @internal */ + private async _publishAsScheduledAzureContainerAppJobInternal(cronExpression: string): Promise { + const rpcArgs: Record = { resource: this._handle, cronExpression }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsScheduledAzureContainerAppJob', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Configures the compute resource as a scheduled Azure Container App Job */ + publishAsScheduledAzureContainerAppJob(cronExpression: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._publishAsScheduledAzureContainerAppJobInternal(cronExpression)); + } + +} + +/** + * Thenable wrapper for ExecutableResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ExecutableResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ExecutableResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Sets the executable command */ + withExecutableCommand(command: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withExecutableCommand(command))); + } + + /** Sets the executable working directory */ + withWorkingDirectory(workingDirectory: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withWorkingDirectory(workingDirectory))); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withEnvironment(name, value))); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withEnvironmentExpression(name, value))); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withEnvironmentCallback(callback))); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withEnvironmentCallbackAsync(callback))); + } + + /** Adds arguments */ + withArgs(args: string[]): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withArgs(args))); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withArgsCallback(callback))); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withArgsCallbackAsync(callback))); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withReference(source, options))); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withServiceReference(source))); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withEndpoint(options))); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withHttpEndpoint(options))); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withHttpsEndpoint(options))); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withExternalHttpEndpoints())); + } + + /** Gets an endpoint reference */ + getEndpoint(name: string): Promise { + return this._promise.then(obj => obj.getEndpoint(name)); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.asHttp2Service())); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withUrlsCallback(callback))); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withUrlsCallbackAsync(callback))); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withUrl(url, options))); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withUrlExpression(url, options))); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withUrlForEndpoint(endpointName, callback))); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withUrlForEndpointFactory(endpointName, callback))); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.waitFor(dependency))); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withExplicitStart())); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.waitForCompletion(dependency, options))); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withHealthCheck(key))); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withHttpHealthCheck(options))); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withParentRelationship(parent))); + } + + /** Gets the resource name */ + getResourceName(): Promise { + return this._promise.then(obj => obj.getResourceName()); + } + + /** Configures the executable resource to be published as an Azure Container App */ + publishAsAzureContainerApp(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.publishAsAzureContainerApp(configure))); + } + + /** Configures the compute resource as an Azure Container App Job with custom configuration */ + publishAsConfiguredAzureContainerAppJob(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.publishAsConfiguredAzureContainerAppJob(configure))); + } + + /** Configures the compute resource as a manually triggered Azure Container App Job */ + publishAsAzureContainerAppJob(): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.publishAsAzureContainerAppJob())); + } + + /** Configures the compute resource as a scheduled Azure Container App Job with custom configuration */ + publishAsConfiguredScheduledAzureContainerAppJob(cronExpression: string, options?: PublishAsConfiguredScheduledAzureContainerAppJobOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.publishAsConfiguredScheduledAzureContainerAppJob(cronExpression, options))); + } + + /** Configures the compute resource as a scheduled Azure Container App Job */ + publishAsScheduledAzureContainerAppJob(cronExpression: string): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.publishAsScheduledAzureContainerAppJob(cronExpression))); + } + +} + +// ============================================================================ +// ParameterResource +// ============================================================================ + +export class ParameterResource extends ResourceBuilderBase { + constructor(handle: ParameterResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withDescriptionInternal(description: string, enableMarkdown?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, description }; + if (enableMarkdown !== undefined) rpcArgs.enableMarkdown = enableMarkdown; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withDescription', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Sets a parameter description */ + withDescription(description: string, options?: WithDescriptionOptions): ParameterResourcePromise { + const enableMarkdown = options?.enableMarkdown; + return new ParameterResourcePromise(this._withDescriptionInternal(description, enableMarkdown)); + } + + /** @internal */ + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; + const obj = new ResourceUrlsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallback', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ParameterResourcePromise { + return new ParameterResourcePromise(this._withUrlsCallbackInternal(callback)); + } + + /** @internal */ + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; + const arg = new ResourceUrlsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallbackAsync', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ParameterResourcePromise { + return new ParameterResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withUrlInternal(url: string, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrl', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ParameterResourcePromise { + const displayText = options?.displayText; + return new ParameterResourcePromise(this._withUrlInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlExpression', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ParameterResourcePromise { + const displayText = options?.displayText; + return new ParameterResourcePromise(this._withUrlExpressionInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpoint', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ParameterResourcePromise { + return new ParameterResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + } + + /** @internal */ + private async _withExplicitStartInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExplicitStart', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ParameterResourcePromise { + return new ParameterResourcePromise(this._withExplicitStartInternal()); + } + + /** @internal */ + private async _withHealthCheckInternal(key: string): Promise { + const rpcArgs: Record = { builder: this._handle, key }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHealthCheck', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ParameterResourcePromise { + return new ParameterResourcePromise(this._withHealthCheckInternal(key)); + } + + /** @internal */ + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + const executeCommandId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; + const arg = new ExecuteCommandContext(argHandle, this._client); + return await executeCommand(arg); + }); + const rpcArgs: Record = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; + if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withCommand', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ParameterResourcePromise { + const commandOptions = options?.commandOptions; + return new ParameterResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + } + + /** @internal */ + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, parent }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withParentRelationship', + rpcArgs + ); + return new ParameterResource(result, this._client); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ParameterResourcePromise { + return new ParameterResourcePromise(this._withParentRelationshipInternal(parent)); + } + + /** Gets the resource name */ + async getResourceName(): Promise { + const rpcArgs: Record = { resource: this._handle }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getResourceName', + rpcArgs + ); + } + +} + +/** + * Thenable wrapper for ParameterResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ParameterResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ParameterResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Sets a parameter description */ + withDescription(description: string, options?: WithDescriptionOptions): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withDescription(description, options))); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withUrlsCallback(callback))); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withUrlsCallbackAsync(callback))); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withUrl(url, options))); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withUrlExpression(url, options))); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withUrlForEndpoint(endpointName, callback))); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withExplicitStart())); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withHealthCheck(key))); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ParameterResourcePromise { + return new ParameterResourcePromise(this._promise.then(obj => obj.withParentRelationship(parent))); + } + + /** Gets the resource name */ + getResourceName(): Promise { + return this._promise.then(obj => obj.getResourceName()); + } + +} + +// ============================================================================ +// ProjectResource +// ============================================================================ + +export class ProjectResource extends ResourceBuilderBase { + constructor(handle: ProjectResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withReplicasInternal(replicas: number): Promise { + const rpcArgs: Record = { builder: this._handle, replicas }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withReplicas', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Sets the number of replicas */ + withReplicas(replicas: number): ProjectResourcePromise { + return new ProjectResourcePromise(this._withReplicasInternal(replicas)); + } + + /** @internal */ + private async _withEnvironmentInternal(name: string, value: string): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironment', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): ProjectResourcePromise { + return new ProjectResourcePromise(this._withEnvironmentInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentExpressionInternal(name: string, value: ReferenceExpression): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentExpression', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): ProjectResourcePromise { + return new ProjectResourcePromise(this._withEnvironmentExpressionInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentCallbackInternal(callback: (obj: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as EnvironmentCallbackContextHandle; + const obj = new EnvironmentCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallback', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._withEnvironmentCallbackInternal(callback)); + } + + /** @internal */ + private async _withEnvironmentCallbackAsyncInternal(callback: (arg: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EnvironmentCallbackContextHandle; + const arg = new EnvironmentCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallbackAsync', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._withEnvironmentCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withArgsInternal(args: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, args }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgs', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds arguments */ + withArgs(args: string[]): ProjectResourcePromise { + return new ProjectResourcePromise(this._withArgsInternal(args)); + } + + /** @internal */ + private async _withArgsCallbackInternal(callback: (obj: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as CommandLineArgsCallbackContextHandle; + const obj = new CommandLineArgsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallback', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._withArgsCallbackInternal(callback)); + } + + /** @internal */ + private async _withArgsCallbackAsyncInternal(callback: (arg: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as CommandLineArgsCallbackContextHandle; + const arg = new CommandLineArgsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallbackAsync', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._withArgsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withReferenceInternal(source: ResourceBuilderBase, connectionName?: string, optional?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + if (connectionName !== undefined) rpcArgs.connectionName = connectionName; + if (optional !== undefined) rpcArgs.optional = optional; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withReference', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): ProjectResourcePromise { + const connectionName = options?.connectionName; + const optional = options?.optional; + return new ProjectResourcePromise(this._withReferenceInternal(source, connectionName, optional)); + } + + /** @internal */ + private async _withServiceReferenceInternal(source: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withServiceReference', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): ProjectResourcePromise { + return new ProjectResourcePromise(this._withServiceReferenceInternal(source)); + } + + /** @internal */ + private async _withEndpointInternal(port?: number, targetPort?: number, scheme?: string, name?: string, env?: string, isProxied?: boolean, isExternal?: boolean, protocol?: ProtocolType): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (scheme !== undefined) rpcArgs.scheme = scheme; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + if (isExternal !== undefined) rpcArgs.isExternal = isExternal; + if (protocol !== undefined) rpcArgs.protocol = protocol; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEndpoint', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): ProjectResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const scheme = options?.scheme; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + const isExternal = options?.isExternal; + const protocol = options?.protocol; + return new ProjectResourcePromise(this._withEndpointInternal(port, targetPort, scheme, name, env, isProxied, isExternal, protocol)); + } + + /** @internal */ + private async _withHttpEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpEndpoint', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): ProjectResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new ProjectResourcePromise(this._withHttpEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withHttpsEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpsEndpoint', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): ProjectResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new ProjectResourcePromise(this._withHttpsEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withExternalHttpEndpointsInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExternalHttpEndpoints', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): ProjectResourcePromise { + return new ProjectResourcePromise(this._withExternalHttpEndpointsInternal()); + } + + /** Gets an endpoint reference */ + async getEndpoint(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getEndpoint', + rpcArgs + ); + } + + /** @internal */ + private async _asHttp2ServiceInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/asHttp2Service', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): ProjectResourcePromise { + return new ProjectResourcePromise(this._asHttp2ServiceInternal()); + } + + /** @internal */ + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; + const obj = new ResourceUrlsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallback', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._withUrlsCallbackInternal(callback)); + } + + /** @internal */ + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; + const arg = new ResourceUrlsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallbackAsync', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withUrlInternal(url: string, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrl', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ProjectResourcePromise { + const displayText = options?.displayText; + return new ProjectResourcePromise(this._withUrlInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlExpression', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ProjectResourcePromise { + const displayText = options?.displayText; + return new ProjectResourcePromise(this._withUrlExpressionInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpoint', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + } + + /** @internal */ + private async _withUrlForEndpointFactoryInternal(endpointName: string, callback: (arg: EndpointReference) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EndpointReferenceHandle; + const arg = new EndpointReference(argHandle, this._client); + return await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpointFactory', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._withUrlForEndpointFactoryInternal(endpointName, callback)); + } + + /** @internal */ + private async _waitForInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitFor', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): ProjectResourcePromise { + return new ProjectResourcePromise(this._waitForInternal(dependency)); + } + + /** @internal */ + private async _withExplicitStartInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExplicitStart', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ProjectResourcePromise { + return new ProjectResourcePromise(this._withExplicitStartInternal()); + } + + /** @internal */ + private async _waitForCompletionInternal(dependency: ResourceBuilderBase, exitCode?: number): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + if (exitCode !== undefined) rpcArgs.exitCode = exitCode; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitForCompletion', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): ProjectResourcePromise { + const exitCode = options?.exitCode; + return new ProjectResourcePromise(this._waitForCompletionInternal(dependency, exitCode)); + } + + /** @internal */ + private async _withHealthCheckInternal(key: string): Promise { + const rpcArgs: Record = { builder: this._handle, key }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHealthCheck', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ProjectResourcePromise { + return new ProjectResourcePromise(this._withHealthCheckInternal(key)); + } + + /** @internal */ + private async _withHttpHealthCheckInternal(path?: string, statusCode?: number, endpointName?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (path !== undefined) rpcArgs.path = path; + if (statusCode !== undefined) rpcArgs.statusCode = statusCode; + if (endpointName !== undefined) rpcArgs.endpointName = endpointName; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpHealthCheck', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): ProjectResourcePromise { + const path = options?.path; + const statusCode = options?.statusCode; + const endpointName = options?.endpointName; + return new ProjectResourcePromise(this._withHttpHealthCheckInternal(path, statusCode, endpointName)); + } + + /** @internal */ + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + const executeCommandId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; + const arg = new ExecuteCommandContext(argHandle, this._client); + return await executeCommand(arg); + }); + const rpcArgs: Record = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; + if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withCommand', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ProjectResourcePromise { + const commandOptions = options?.commandOptions; + return new ProjectResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + } + + /** @internal */ + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, parent }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withParentRelationship', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ProjectResourcePromise { + return new ProjectResourcePromise(this._withParentRelationshipInternal(parent)); + } + + /** Gets the resource name */ + async getResourceName(): Promise { + const rpcArgs: Record = { resource: this._handle }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getResourceName', + rpcArgs + ); + } + + /** @internal */ + private async _publishAsAzureContainerAppInternal(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): Promise { + const configureId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppHandle; + await configure(arg1, arg2); + }); + const rpcArgs: Record = { project: this._handle, configure: configureId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishProjectAsAzureContainerApp', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Configures the project resource to be published as an Azure Container App */ + publishAsAzureContainerApp(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._publishAsAzureContainerAppInternal(configure)); + } + + /** @internal */ + private async _publishAsConfiguredAzureContainerAppJobInternal(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): Promise { + const configureId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppJobHandle; + await configure(arg1, arg2); + }); + const rpcArgs: Record = { resource: this._handle, configure: configureId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsConfiguredAzureContainerAppJob', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Configures the compute resource as an Azure Container App Job with custom configuration */ + publishAsConfiguredAzureContainerAppJob(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._publishAsConfiguredAzureContainerAppJobInternal(configure)); + } + + /** @internal */ + private async _publishAsAzureContainerAppJobInternal(): Promise { + const rpcArgs: Record = { resource: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsAzureContainerAppJob', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Configures the compute resource as a manually triggered Azure Container App Job */ + publishAsAzureContainerAppJob(): ProjectResourcePromise { + return new ProjectResourcePromise(this._publishAsAzureContainerAppJobInternal()); + } + + /** @internal */ + private async _publishAsConfiguredScheduledAzureContainerAppJobInternal(cronExpression: string, configure?: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): Promise { + const configureId = configure ? registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppJobHandle; + await configure(arg1, arg2); + }) : undefined; + const rpcArgs: Record = { resource: this._handle, cronExpression }; + if (configure !== undefined) rpcArgs.configure = configureId; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsConfiguredScheduledAzureContainerAppJob', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Configures the compute resource as a scheduled Azure Container App Job with custom configuration */ + publishAsConfiguredScheduledAzureContainerAppJob(cronExpression: string, options?: PublishAsConfiguredScheduledAzureContainerAppJobOptions): ProjectResourcePromise { + const configure = options?.configure; + return new ProjectResourcePromise(this._publishAsConfiguredScheduledAzureContainerAppJobInternal(cronExpression, configure)); + } + + /** @internal */ + private async _publishAsScheduledAzureContainerAppJobInternal(cronExpression: string): Promise { + const rpcArgs: Record = { resource: this._handle, cronExpression }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsScheduledAzureContainerAppJob', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Configures the compute resource as a scheduled Azure Container App Job */ + publishAsScheduledAzureContainerAppJob(cronExpression: string): ProjectResourcePromise { + return new ProjectResourcePromise(this._publishAsScheduledAzureContainerAppJobInternal(cronExpression)); + } + +} + +/** + * Thenable wrapper for ProjectResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ProjectResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ProjectResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Sets the number of replicas */ + withReplicas(replicas: number): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withReplicas(replicas))); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withEnvironment(name, value))); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withEnvironmentExpression(name, value))); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withEnvironmentCallback(callback))); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withEnvironmentCallbackAsync(callback))); + } + + /** Adds arguments */ + withArgs(args: string[]): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withArgs(args))); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withArgsCallback(callback))); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withArgsCallbackAsync(callback))); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withReference(source, options))); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withServiceReference(source))); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withEndpoint(options))); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withHttpEndpoint(options))); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withHttpsEndpoint(options))); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withExternalHttpEndpoints())); + } + + /** Gets an endpoint reference */ + getEndpoint(name: string): Promise { + return this._promise.then(obj => obj.getEndpoint(name)); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.asHttp2Service())); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withUrlsCallback(callback))); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withUrlsCallbackAsync(callback))); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withUrl(url, options))); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withUrlExpression(url, options))); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withUrlForEndpoint(endpointName, callback))); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withUrlForEndpointFactory(endpointName, callback))); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.waitFor(dependency))); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withExplicitStart())); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.waitForCompletion(dependency, options))); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withHealthCheck(key))); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withHttpHealthCheck(options))); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withParentRelationship(parent))); + } + + /** Gets the resource name */ + getResourceName(): Promise { + return this._promise.then(obj => obj.getResourceName()); + } + + /** Configures the project resource to be published as an Azure Container App */ + publishAsAzureContainerApp(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppHandle) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.publishAsAzureContainerApp(configure))); + } + + /** Configures the compute resource as an Azure Container App Job with custom configuration */ + publishAsConfiguredAzureContainerAppJob(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.publishAsConfiguredAzureContainerAppJob(configure))); + } + + /** Configures the compute resource as a manually triggered Azure Container App Job */ + publishAsAzureContainerAppJob(): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.publishAsAzureContainerAppJob())); + } + + /** Configures the compute resource as a scheduled Azure Container App Job with custom configuration */ + publishAsConfiguredScheduledAzureContainerAppJob(cronExpression: string, options?: PublishAsConfiguredScheduledAzureContainerAppJobOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.publishAsConfiguredScheduledAzureContainerAppJob(cronExpression, options))); + } + + /** Configures the compute resource as a scheduled Azure Container App Job */ + publishAsScheduledAzureContainerAppJob(cronExpression: string): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.publishAsScheduledAzureContainerAppJob(cronExpression))); + } + +} + +// ============================================================================ +// ComputeResource +// ============================================================================ + +export class ComputeResource extends ResourceBuilderBase { + constructor(handle: IComputeResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _publishAsConfiguredAzureContainerAppJobInternal(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): Promise { + const configureId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppJobHandle; + await configure(arg1, arg2); + }); + const rpcArgs: Record = { resource: this._handle, configure: configureId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsConfiguredAzureContainerAppJob', + rpcArgs + ); + return new ComputeResource(result, this._client); + } + + /** Configures the compute resource as an Azure Container App Job with custom configuration */ + publishAsConfiguredAzureContainerAppJob(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): ComputeResourcePromise { + return new ComputeResourcePromise(this._publishAsConfiguredAzureContainerAppJobInternal(configure)); + } + + /** @internal */ + private async _publishAsAzureContainerAppJobInternal(): Promise { + const rpcArgs: Record = { resource: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsAzureContainerAppJob', + rpcArgs + ); + return new ComputeResource(result, this._client); + } + + /** Configures the compute resource as a manually triggered Azure Container App Job */ + publishAsAzureContainerAppJob(): ComputeResourcePromise { + return new ComputeResourcePromise(this._publishAsAzureContainerAppJobInternal()); + } + + /** @internal */ + private async _publishAsConfiguredScheduledAzureContainerAppJobInternal(cronExpression: string, configure?: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): Promise { + const configureId = configure ? registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1 = wrapIfHandle(args.p0) as AzureResourceInfrastructureHandle; + const arg2 = wrapIfHandle(args.p1) as ContainerAppJobHandle; + await configure(arg1, arg2); + }) : undefined; + const rpcArgs: Record = { resource: this._handle, cronExpression }; + if (configure !== undefined) rpcArgs.configure = configureId; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsConfiguredScheduledAzureContainerAppJob', + rpcArgs + ); + return new ComputeResource(result, this._client); + } + + /** Configures the compute resource as a scheduled Azure Container App Job with custom configuration */ + publishAsConfiguredScheduledAzureContainerAppJob(cronExpression: string, options?: PublishAsConfiguredScheduledAzureContainerAppJobOptions): ComputeResourcePromise { + const configure = options?.configure; + return new ComputeResourcePromise(this._publishAsConfiguredScheduledAzureContainerAppJobInternal(cronExpression, configure)); + } + + /** @internal */ + private async _publishAsScheduledAzureContainerAppJobInternal(cronExpression: string): Promise { + const rpcArgs: Record = { resource: this._handle, cronExpression }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.Azure.AppContainers/publishAsScheduledAzureContainerAppJob', + rpcArgs + ); + return new ComputeResource(result, this._client); + } + + /** Configures the compute resource as a scheduled Azure Container App Job */ + publishAsScheduledAzureContainerAppJob(cronExpression: string): ComputeResourcePromise { + return new ComputeResourcePromise(this._publishAsScheduledAzureContainerAppJobInternal(cronExpression)); + } + +} + +/** + * Thenable wrapper for ComputeResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ComputeResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ComputeResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Configures the compute resource as an Azure Container App Job with custom configuration */ + publishAsConfiguredAzureContainerAppJob(configure: (arg1: AzureResourceInfrastructureHandle, arg2: ContainerAppJobHandle) => Promise): ComputeResourcePromise { + return new ComputeResourcePromise(this._promise.then(obj => obj.publishAsConfiguredAzureContainerAppJob(configure))); + } + + /** Configures the compute resource as a manually triggered Azure Container App Job */ + publishAsAzureContainerAppJob(): ComputeResourcePromise { + return new ComputeResourcePromise(this._promise.then(obj => obj.publishAsAzureContainerAppJob())); + } + + /** Configures the compute resource as a scheduled Azure Container App Job with custom configuration */ + publishAsConfiguredScheduledAzureContainerAppJob(cronExpression: string, options?: PublishAsConfiguredScheduledAzureContainerAppJobOptions): ComputeResourcePromise { + return new ComputeResourcePromise(this._promise.then(obj => obj.publishAsConfiguredScheduledAzureContainerAppJob(cronExpression, options))); + } + + /** Configures the compute resource as a scheduled Azure Container App Job */ + publishAsScheduledAzureContainerAppJob(cronExpression: string): ComputeResourcePromise { + return new ComputeResourcePromise(this._promise.then(obj => obj.publishAsScheduledAzureContainerAppJob(cronExpression))); + } + +} + +// ============================================================================ +// Resource +// ============================================================================ + +export class Resource extends ResourceBuilderBase { + constructor(handle: IResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; + const obj = new ResourceUrlsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallback', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ResourcePromise { + return new ResourcePromise(this._withUrlsCallbackInternal(callback)); + } + + /** @internal */ + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; + const arg = new ResourceUrlsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallbackAsync', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ResourcePromise { + return new ResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withUrlInternal(url: string, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrl', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ResourcePromise { + const displayText = options?.displayText; + return new ResourcePromise(this._withUrlInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlExpression', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ResourcePromise { + const displayText = options?.displayText; + return new ResourcePromise(this._withUrlExpressionInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpoint', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ResourcePromise { + return new ResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + } + + /** @internal */ + private async _withExplicitStartInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExplicitStart', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ResourcePromise { + return new ResourcePromise(this._withExplicitStartInternal()); + } + + /** @internal */ + private async _withHealthCheckInternal(key: string): Promise { + const rpcArgs: Record = { builder: this._handle, key }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHealthCheck', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ResourcePromise { + return new ResourcePromise(this._withHealthCheckInternal(key)); + } + + /** @internal */ + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + const executeCommandId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; + const arg = new ExecuteCommandContext(argHandle, this._client); + return await executeCommand(arg); + }); + const rpcArgs: Record = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; + if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withCommand', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ResourcePromise { + const commandOptions = options?.commandOptions; + return new ResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + } + + /** @internal */ + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, parent }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withParentRelationship', + rpcArgs + ); + return new Resource(result, this._client); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ResourcePromise { + return new ResourcePromise(this._withParentRelationshipInternal(parent)); + } + + /** Gets the resource name */ + async getResourceName(): Promise { + const rpcArgs: Record = { resource: this._handle }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getResourceName', + rpcArgs + ); + } + +} + +/** + * Thenable wrapper for Resource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: Resource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withUrlsCallback(callback))); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withUrlsCallbackAsync(callback))); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withUrl(url, options))); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withUrlExpression(url, options))); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withUrlForEndpoint(endpointName, callback))); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withExplicitStart())); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withHealthCheck(key))); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): ResourcePromise { + return new ResourcePromise(this._promise.then(obj => obj.withParentRelationship(parent))); + } + + /** Gets the resource name */ + getResourceName(): Promise { + return this._promise.then(obj => obj.getResourceName()); + } + +} + +// ============================================================================ +// ResourceWithArgs +// ============================================================================ + +export class ResourceWithArgs extends ResourceBuilderBase { + constructor(handle: IResourceWithArgsHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withArgsInternal(args: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, args }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgs', + rpcArgs + ); + return new ResourceWithArgs(result, this._client); + } + + /** Adds arguments */ + withArgs(args: string[]): ResourceWithArgsPromise { + return new ResourceWithArgsPromise(this._withArgsInternal(args)); + } + + /** @internal */ + private async _withArgsCallbackInternal(callback: (obj: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as CommandLineArgsCallbackContextHandle; + const obj = new CommandLineArgsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallback', + rpcArgs + ); + return new ResourceWithArgs(result, this._client); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): ResourceWithArgsPromise { + return new ResourceWithArgsPromise(this._withArgsCallbackInternal(callback)); + } + + /** @internal */ + private async _withArgsCallbackAsyncInternal(callback: (arg: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as CommandLineArgsCallbackContextHandle; + const arg = new CommandLineArgsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallbackAsync', + rpcArgs + ); + return new ResourceWithArgs(result, this._client); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): ResourceWithArgsPromise { + return new ResourceWithArgsPromise(this._withArgsCallbackAsyncInternal(callback)); + } + +} + +/** + * Thenable wrapper for ResourceWithArgs that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ResourceWithArgsPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ResourceWithArgs) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Adds arguments */ + withArgs(args: string[]): ResourceWithArgsPromise { + return new ResourceWithArgsPromise(this._promise.then(obj => obj.withArgs(args))); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): ResourceWithArgsPromise { + return new ResourceWithArgsPromise(this._promise.then(obj => obj.withArgsCallback(callback))); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): ResourceWithArgsPromise { + return new ResourceWithArgsPromise(this._promise.then(obj => obj.withArgsCallbackAsync(callback))); + } + +} + +// ============================================================================ +// ResourceWithConnectionString +// ============================================================================ + +export class ResourceWithConnectionString extends ResourceBuilderBase { + constructor(handle: IResourceWithConnectionStringHandle, client: AspireClientRpc) { + super(handle, client); + } + +} + +/** + * Thenable wrapper for ResourceWithConnectionString that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ResourceWithConnectionStringPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ResourceWithConnectionString) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + +} + +// ============================================================================ +// ResourceWithEndpoints +// ============================================================================ + +export class ResourceWithEndpoints extends ResourceBuilderBase { + constructor(handle: IResourceWithEndpointsHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withEndpointInternal(port?: number, targetPort?: number, scheme?: string, name?: string, env?: string, isProxied?: boolean, isExternal?: boolean, protocol?: ProtocolType): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (scheme !== undefined) rpcArgs.scheme = scheme; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + if (isExternal !== undefined) rpcArgs.isExternal = isExternal; + if (protocol !== undefined) rpcArgs.protocol = protocol; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEndpoint', + rpcArgs + ); + return new ResourceWithEndpoints(result, this._client); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): ResourceWithEndpointsPromise { + const port = options?.port; + const targetPort = options?.targetPort; + const scheme = options?.scheme; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + const isExternal = options?.isExternal; + const protocol = options?.protocol; + return new ResourceWithEndpointsPromise(this._withEndpointInternal(port, targetPort, scheme, name, env, isProxied, isExternal, protocol)); + } + + /** @internal */ + private async _withHttpEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpEndpoint', + rpcArgs + ); + return new ResourceWithEndpoints(result, this._client); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): ResourceWithEndpointsPromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new ResourceWithEndpointsPromise(this._withHttpEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withHttpsEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpsEndpoint', + rpcArgs + ); + return new ResourceWithEndpoints(result, this._client); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): ResourceWithEndpointsPromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new ResourceWithEndpointsPromise(this._withHttpsEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withExternalHttpEndpointsInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExternalHttpEndpoints', + rpcArgs + ); + return new ResourceWithEndpoints(result, this._client); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._withExternalHttpEndpointsInternal()); + } + + /** Gets an endpoint reference */ + async getEndpoint(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getEndpoint', + rpcArgs + ); + } + + /** @internal */ + private async _asHttp2ServiceInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/asHttp2Service', + rpcArgs + ); + return new ResourceWithEndpoints(result, this._client); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._asHttp2ServiceInternal()); + } + + /** @internal */ + private async _withUrlForEndpointFactoryInternal(endpointName: string, callback: (arg: EndpointReference) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EndpointReferenceHandle; + const arg = new EndpointReference(argHandle, this._client); + return await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpointFactory', + rpcArgs + ); + return new ResourceWithEndpoints(result, this._client); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._withUrlForEndpointFactoryInternal(endpointName, callback)); + } + + /** @internal */ + private async _withHttpHealthCheckInternal(path?: string, statusCode?: number, endpointName?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (path !== undefined) rpcArgs.path = path; + if (statusCode !== undefined) rpcArgs.statusCode = statusCode; + if (endpointName !== undefined) rpcArgs.endpointName = endpointName; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpHealthCheck', + rpcArgs + ); + return new ResourceWithEndpoints(result, this._client); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): ResourceWithEndpointsPromise { + const path = options?.path; + const statusCode = options?.statusCode; + const endpointName = options?.endpointName; + return new ResourceWithEndpointsPromise(this._withHttpHealthCheckInternal(path, statusCode, endpointName)); + } + +} + +/** + * Thenable wrapper for ResourceWithEndpoints that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ResourceWithEndpointsPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ResourceWithEndpoints) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.withEndpoint(options))); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.withHttpEndpoint(options))); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.withHttpsEndpoint(options))); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.withExternalHttpEndpoints())); + } + + /** Gets an endpoint reference */ + getEndpoint(name: string): Promise { + return this._promise.then(obj => obj.getEndpoint(name)); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.asHttp2Service())); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.withUrlForEndpointFactory(endpointName, callback))); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.withHttpHealthCheck(options))); + } + +} + +// ============================================================================ +// ResourceWithEnvironment +// ============================================================================ + +export class ResourceWithEnvironment extends ResourceBuilderBase { + constructor(handle: IResourceWithEnvironmentHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withEnvironmentInternal(name: string, value: string): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironment', + rpcArgs + ); + return new ResourceWithEnvironment(result, this._client); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._withEnvironmentInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentExpressionInternal(name: string, value: ReferenceExpression): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentExpression', + rpcArgs + ); + return new ResourceWithEnvironment(result, this._client); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._withEnvironmentExpressionInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentCallbackInternal(callback: (obj: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as EnvironmentCallbackContextHandle; + const obj = new EnvironmentCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallback', + rpcArgs + ); + return new ResourceWithEnvironment(result, this._client); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._withEnvironmentCallbackInternal(callback)); + } + + /** @internal */ + private async _withEnvironmentCallbackAsyncInternal(callback: (arg: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EnvironmentCallbackContextHandle; + const arg = new EnvironmentCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallbackAsync', + rpcArgs + ); + return new ResourceWithEnvironment(result, this._client); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._withEnvironmentCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withReferenceInternal(source: ResourceBuilderBase, connectionName?: string, optional?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + if (connectionName !== undefined) rpcArgs.connectionName = connectionName; + if (optional !== undefined) rpcArgs.optional = optional; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withReference', + rpcArgs + ); + return new ResourceWithEnvironment(result, this._client); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): ResourceWithEnvironmentPromise { + const connectionName = options?.connectionName; + const optional = options?.optional; + return new ResourceWithEnvironmentPromise(this._withReferenceInternal(source, connectionName, optional)); + } + + /** @internal */ + private async _withServiceReferenceInternal(source: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withServiceReference', + rpcArgs + ); + return new ResourceWithEnvironment(result, this._client); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._withServiceReferenceInternal(source)); + } + +} + +/** + * Thenable wrapper for ResourceWithEnvironment that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ResourceWithEnvironmentPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ResourceWithEnvironment) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._promise.then(obj => obj.withEnvironment(name, value))); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._promise.then(obj => obj.withEnvironmentExpression(name, value))); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._promise.then(obj => obj.withEnvironmentCallback(callback))); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._promise.then(obj => obj.withEnvironmentCallbackAsync(callback))); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._promise.then(obj => obj.withReference(source, options))); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): ResourceWithEnvironmentPromise { + return new ResourceWithEnvironmentPromise(this._promise.then(obj => obj.withServiceReference(source))); + } + +} + +// ============================================================================ +// ResourceWithServiceDiscovery +// ============================================================================ + +export class ResourceWithServiceDiscovery extends ResourceBuilderBase { + constructor(handle: IResourceWithServiceDiscoveryHandle, client: AspireClientRpc) { + super(handle, client); + } + +} + +/** + * Thenable wrapper for ResourceWithServiceDiscovery that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ResourceWithServiceDiscoveryPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ResourceWithServiceDiscovery) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + +} + +// ============================================================================ +// ResourceWithWaitSupport +// ============================================================================ + +export class ResourceWithWaitSupport extends ResourceBuilderBase { + constructor(handle: IResourceWithWaitSupportHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _waitForInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitFor', + rpcArgs + ); + return new ResourceWithWaitSupport(result, this._client); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): ResourceWithWaitSupportPromise { + return new ResourceWithWaitSupportPromise(this._waitForInternal(dependency)); + } + + /** @internal */ + private async _waitForCompletionInternal(dependency: ResourceBuilderBase, exitCode?: number): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + if (exitCode !== undefined) rpcArgs.exitCode = exitCode; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitForCompletion', + rpcArgs + ); + return new ResourceWithWaitSupport(result, this._client); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): ResourceWithWaitSupportPromise { + const exitCode = options?.exitCode; + return new ResourceWithWaitSupportPromise(this._waitForCompletionInternal(dependency, exitCode)); + } + +} + +/** + * Thenable wrapper for ResourceWithWaitSupport that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class ResourceWithWaitSupportPromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: ResourceWithWaitSupport) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): ResourceWithWaitSupportPromise { + return new ResourceWithWaitSupportPromise(this._promise.then(obj => obj.waitFor(dependency))); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): ResourceWithWaitSupportPromise { + return new ResourceWithWaitSupportPromise(this._promise.then(obj => obj.waitForCompletion(dependency, options))); + } + +} + +// ============================================================================ +// Connection Helper +// ============================================================================ + +/** + * Creates and connects to the Aspire AppHost. + * Reads connection info from environment variables set by `aspire run`. + */ +export async function connect(): Promise { + const socketPath = process.env.REMOTE_APP_HOST_SOCKET_PATH; + if (!socketPath) { + throw new Error( + 'REMOTE_APP_HOST_SOCKET_PATH environment variable not set. ' + + 'Run this application using `aspire run`.' + ); + } + + const client = new AspireClientRpc(socketPath); + await client.connect(); + + // Exit the process if the server connection is lost + client.onDisconnect(() => { + console.error('Connection to AppHost lost. Exiting...'); + process.exit(1); + }); + + return client; +} + +/** + * Creates a new distributed application builder. + * This is the entry point for building Aspire applications. + * + * @param options - Optional configuration options for the builder + * @returns A DistributedApplicationBuilder instance + * + * @example + * const builder = await createBuilder(); + * builder.addRedis("cache"); + * builder.addContainer("api", "mcr.microsoft.com/dotnet/samples:aspnetapp"); + * const app = await builder.build(); + * await app.run(); + */ +export async function createBuilder(options?: CreateBuilderOptions): Promise { + const client = await connect(); + + // Default args, projectDirectory, and appHostFilePath if not provided + // ASPIRE_APPHOST_FILEPATH is set by the CLI for consistent socket hash computation + const effectiveOptions: CreateBuilderOptions = { + ...options, + args: options?.args ?? process.argv.slice(2), + projectDirectory: options?.projectDirectory ?? process.env.ASPIRE_PROJECT_DIRECTORY ?? process.cwd(), + appHostFilePath: options?.appHostFilePath ?? process.env.ASPIRE_APPHOST_FILEPATH + }; + + const handle = await client.invokeCapability( + 'Aspire.Hosting/createBuilderWithOptions', + { options: effectiveOptions } + ); + return new DistributedApplicationBuilder(handle, client); +} + +// Re-export commonly used types +export { Handle, CapabilityError, registerCallback } from './transport.js'; +export { refExpr, ReferenceExpression } from './base.js'; + +// ============================================================================ +// Global Error Handling +// ============================================================================ + +/** + * Set up global error handlers to ensure the process exits properly on errors. + * Node.js doesn't exit on unhandled rejections by default, so we need to handle them. + */ +process.on('unhandledRejection', (reason: unknown) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + + if (reason instanceof CapabilityError) { + console.error(`\n❌ Capability Error: ${error.message}`); + console.error(` Code: ${(reason as CapabilityError).code}`); + if ((reason as CapabilityError).capability) { + console.error(` Capability: ${(reason as CapabilityError).capability}`); + } + } else { + console.error(`\n❌ Unhandled Error: ${error.message}`); + if (error.stack) { + console.error(error.stack); + } + } + + process.exit(1); +}); + +process.on('uncaughtException', (error: Error) => { + console.error(`\n❌ Uncaught Exception: ${error.message}`); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); +}); + +// ============================================================================ +// Handle Wrapper Registrations +// ============================================================================ + +// Register wrapper factories for typed handle wrapping in callbacks +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext', (handle, client) => new CommandLineArgsCallbackContext(handle as CommandLineArgsCallbackContextHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.DistributedApplication', (handle, client) => new DistributedApplication(handle as DistributedApplicationHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext', (handle, client) => new DistributedApplicationExecutionContext(handle as DistributedApplicationExecutionContextHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference', (handle, client) => new EndpointReference(handle as EndpointReferenceHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression', (handle, client) => new EndpointReferenceExpression(handle as EndpointReferenceExpressionHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext', (handle, client) => new EnvironmentCallbackContext(handle as EnvironmentCallbackContextHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext', (handle, client) => new ExecuteCommandContext(handle as ExecuteCommandContextHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext', (handle, client) => new ResourceUrlsCallbackContext(handle as ResourceUrlsCallbackContextHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder', (handle, client) => new DistributedApplicationBuilder(handle as IDistributedApplicationBuilderHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEventing', (handle, client) => new DistributedApplicationEventing(handle as IDistributedApplicationEventingHandle, client)); +registerHandleWrapper('Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.AzureContainerAppEnvironmentResource', (handle, client) => new AzureContainerAppEnvironmentResource(handle as AzureContainerAppEnvironmentResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting.Azure.OperationalInsights/Aspire.Hosting.Azure.AzureLogAnalyticsWorkspaceResource', (handle, client) => new AzureLogAnalyticsWorkspaceResource(handle as AzureLogAnalyticsWorkspaceResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource', (handle, client) => new ContainerResource(handle as ContainerResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource', (handle, client) => new ExecutableResource(handle as ExecutableResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource', (handle, client) => new ParameterResource(handle as ParameterResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource', (handle, client) => new ProjectResource(handle as ProjectResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IComputeResource', (handle, client) => new ComputeResource(handle as IComputeResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource', (handle, client) => new Resource(handle as IResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs', (handle, client) => new ResourceWithArgs(handle as IResourceWithArgsHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString', (handle, client) => new ResourceWithConnectionString(handle as IResourceWithConnectionStringHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints', (handle, client) => new ResourceWithEndpoints(handle as IResourceWithEndpointsHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment', (handle, client) => new ResourceWithEnvironment(handle as IResourceWithEnvironmentHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery', (handle, client) => new ResourceWithServiceDiscovery(handle as IResourceWithServiceDiscoveryHandle, client)); +registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport', (handle, client) => new ResourceWithWaitSupport(handle as IResourceWithWaitSupportHandle, client)); + diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/base.ts b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/base.ts new file mode 100644 index 00000000000..d25499d7b82 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/base.ts @@ -0,0 +1,453 @@ +// aspire.ts - Core Aspire types: base classes, ReferenceExpression +import { Handle, AspireClient, MarshalledHandle } from './transport.js'; + +// Re-export transport types for convenience +export { Handle, AspireClient, CapabilityError, registerCallback, unregisterCallback, registerCancellation, unregisterCancellation } from './transport.js'; +export type { MarshalledHandle, AtsError, AtsErrorDetails, CallbackFunction } from './transport.js'; +export { AtsErrorCodes, isMarshalledHandle, isAtsError, wrapIfHandle } from './transport.js'; + +// ============================================================================ +// Reference Expression +// ============================================================================ + +/** + * Represents a reference expression that can be passed to capabilities. + * + * Reference expressions are serialized in the protocol as: + * ```json + * { + * "$expr": { + * "format": "redis://{0}:{1}", + * "valueProviders": [ + * { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReference:1" }, + * { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReference:2" } + * ] + * } + * } + * ``` + * + * @example + * ```typescript + * const redis = await builder.addRedis("cache"); + * const endpoint = await redis.getEndpoint("tcp"); + * + * // Create a reference expression + * const expr = refExpr`redis://${endpoint}:6379`; + * + * // Use it in an environment variable + * await api.withEnvironment("REDIS_URL", expr); + * ``` + */ +export class ReferenceExpression { + private readonly _format: string; + private readonly _valueProviders: unknown[]; + + private constructor(format: string, valueProviders: unknown[]) { + this._format = format; + this._valueProviders = valueProviders; + } + + /** + * Creates a reference expression from a tagged template literal. + * + * @param strings - The template literal string parts + * @param values - The interpolated values (handles to value providers) + * @returns A ReferenceExpression instance + */ + static create(strings: TemplateStringsArray, ...values: unknown[]): ReferenceExpression { + // Build the format string with {0}, {1}, etc. placeholders + let format = ''; + for (let i = 0; i < strings.length; i++) { + format += strings[i]; + if (i < values.length) { + format += `{${i}}`; + } + } + + // Extract handles from values + const valueProviders = values.map(extractHandleForExpr); + + return new ReferenceExpression(format, valueProviders); + } + + /** + * Serializes the reference expression for JSON-RPC transport. + * Uses the $expr format recognized by the server. + */ + toJSON(): { $expr: { format: string; valueProviders?: unknown[] } } { + return { + $expr: { + format: this._format, + valueProviders: this._valueProviders.length > 0 ? this._valueProviders : undefined + } + }; + } + + /** + * String representation for debugging. + */ + toString(): string { + return `ReferenceExpression(${this._format})`; + } +} + +/** + * Extracts a value for use in reference expressions. + * Supports handles (objects) and string literals. + * @internal + */ +function extractHandleForExpr(value: unknown): unknown { + if (value === null || value === undefined) { + throw new Error('Cannot use null or undefined in reference expression'); + } + + // String literals - include directly in the expression + if (typeof value === 'string') { + return value; + } + + // Number literals - convert to string + if (typeof value === 'number') { + return String(value); + } + + // Handle objects - get their JSON representation + if (value instanceof Handle) { + return value.toJSON(); + } + + // Objects with $handle property (already in handle format) + if (typeof value === 'object' && value !== null && '$handle' in value) { + return value; + } + + // Objects with toJSON that returns a handle + if (typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function') { + const json = value.toJSON(); + if (json && typeof json === 'object' && '$handle' in json) { + return json; + } + } + + throw new Error( + `Cannot use value of type ${typeof value} in reference expression. ` + + `Expected a Handle, string, or number.` + ); +} + +/** + * Tagged template function for creating reference expressions. + * + * Use this to create dynamic expressions that reference endpoints, parameters, and other + * value providers. The expression is evaluated at runtime by Aspire. + * + * @example + * ```typescript + * const redis = await builder.addRedis("cache"); + * const endpoint = await redis.getEndpoint("tcp"); + * + * // Create a reference expression using the tagged template + * const expr = refExpr`redis://${endpoint}:6379`; + * + * // Use it in an environment variable + * await api.withEnvironment("REDIS_URL", expr); + * ``` + */ +export function refExpr(strings: TemplateStringsArray, ...values: unknown[]): ReferenceExpression { + return ReferenceExpression.create(strings, ...values); +} + +// ============================================================================ +// ResourceBuilderBase +// ============================================================================ + +/** + * Base class for resource builders (e.g., RedisBuilder, ContainerBuilder). + * Provides handle management and JSON serialization. + */ +export class ResourceBuilderBase { + constructor(protected _handle: THandle, protected _client: AspireClient) {} + + toJSON(): MarshalledHandle { return this._handle.toJSON(); } +} + +// ============================================================================ +// AspireList - Mutable List Wrapper +// ============================================================================ + +/** + * Wrapper for a mutable .NET List. + * Provides array-like methods that invoke capabilities on the underlying collection. + * + * @example + * ```typescript + * const items = await resource.getItems(); // Returns AspireList + * const count = await items.count(); + * const first = await items.get(0); + * await items.add(newItem); + * ``` + */ +export class AspireList { + private _resolvedHandle?: Handle; + private _resolvePromise?: Promise; + + constructor( + private readonly _handleOrContext: Handle, + private readonly _client: AspireClient, + private readonly _typeId: string, + private readonly _getterCapabilityId?: string + ) { + // If no getter capability, the handle is already the list handle + if (!_getterCapabilityId) { + this._resolvedHandle = _handleOrContext; + } + } + + /** + * Ensures we have the actual list handle by calling the getter if needed. + */ + private async _ensureHandle(): Promise { + if (this._resolvedHandle) { + return this._resolvedHandle; + } + if (this._resolvePromise) { + return this._resolvePromise; + } + // Call the getter capability to get the actual list handle + this._resolvePromise = (async () => { + const result = await this._client.invokeCapability(this._getterCapabilityId!, { + context: this._handleOrContext + }); + this._resolvedHandle = result as Handle; + return this._resolvedHandle; + })(); + return this._resolvePromise; + } + + /** + * Gets the number of elements in the list. + */ + async count(): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/List.length', { + list: handle + }) as number; + } + + /** + * Gets the element at the specified index. + */ + async get(index: number): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/List.get', { + list: handle, + index + }) as T; + } + + /** + * Adds an element to the end of the list. + */ + async add(item: T): Promise { + const handle = await this._ensureHandle(); + await this._client.invokeCapability('Aspire.Hosting/List.add', { + list: handle, + item + }); + } + + /** + * Removes the element at the specified index. + */ + async removeAt(index: number): Promise { + const handle = await this._ensureHandle(); + await this._client.invokeCapability('Aspire.Hosting/List.removeAt', { + list: handle, + index + }); + } + + /** + * Clears all elements from the list. + */ + async clear(): Promise { + const handle = await this._ensureHandle(); + await this._client.invokeCapability('Aspire.Hosting/List.clear', { + list: handle + }); + } + + /** + * Converts the list to an array (creates a copy). + */ + async toArray(): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/List.toArray', { + list: handle + }) as T[]; + } + + toJSON(): MarshalledHandle { + if (this._resolvedHandle) { + return this._resolvedHandle.toJSON(); + } + return this._handleOrContext.toJSON(); + } +} + +// ============================================================================ +// AspireDict - Mutable Dictionary Wrapper +// ============================================================================ + +/** + * Wrapper for a mutable .NET Dictionary. + * Provides object-like methods that invoke capabilities on the underlying collection. + * + * @example + * ```typescript + * const config = await resource.getConfig(); // Returns AspireDict + * const value = await config.get("key"); + * await config.set("key", "value"); + * const hasKey = await config.containsKey("key"); + * ``` + */ +export class AspireDict { + private _resolvedHandle?: Handle; + private _resolvePromise?: Promise; + + constructor( + private readonly _handleOrContext: Handle, + private readonly _client: AspireClient, + private readonly _typeId: string, + private readonly _getterCapabilityId?: string + ) { + // If no getter capability, the handle is already the dictionary handle + if (!_getterCapabilityId) { + this._resolvedHandle = _handleOrContext; + } + } + + /** + * Ensures we have the actual dictionary handle by calling the getter if needed. + */ + private async _ensureHandle(): Promise { + if (this._resolvedHandle) { + return this._resolvedHandle; + } + if (this._resolvePromise) { + return this._resolvePromise; + } + // Call the getter capability to get the actual dictionary handle + this._resolvePromise = (async () => { + const result = await this._client.invokeCapability(this._getterCapabilityId!, { + context: this._handleOrContext + }); + this._resolvedHandle = result as Handle; + return this._resolvedHandle; + })(); + return this._resolvePromise; + } + + /** + * Gets the number of key-value pairs in the dictionary. + */ + async count(): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.count', { + dict: handle + }) as number; + } + + /** + * Gets the value associated with the specified key. + * @throws If the key is not found. + */ + async get(key: K): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.get', { + dict: handle, + key + }) as V; + } + + /** + * Sets the value for the specified key. + */ + async set(key: K, value: V): Promise { + const handle = await this._ensureHandle(); + await this._client.invokeCapability('Aspire.Hosting/Dict.set', { + dict: handle, + key, + value + }); + } + + /** + * Determines whether the dictionary contains the specified key. + */ + async containsKey(key: K): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.has', { + dict: handle, + key + }) as boolean; + } + + /** + * Removes the value with the specified key. + * @returns True if the element was removed; false if the key was not found. + */ + async remove(key: K): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.remove', { + dict: handle, + key + }) as boolean; + } + + /** + * Clears all key-value pairs from the dictionary. + */ + async clear(): Promise { + const handle = await this._ensureHandle(); + await this._client.invokeCapability('Aspire.Hosting/Dict.clear', { + dict: handle + }); + } + + /** + * Gets all keys in the dictionary. + */ + async keys(): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.keys', { + dict: handle + }) as K[]; + } + + /** + * Gets all values in the dictionary. + */ + async values(): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.values', { + dict: handle + }) as V[]; + } + + /** + * Converts the dictionary to a plain object (creates a copy). + * Only works when K is string. + */ + async toObject(): Promise> { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.toObject', { + dict: handle + }) as Record; + } + + async toJSON(): Promise { + const handle = await this._ensureHandle(); + return handle.toJSON(); + } +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/transport.ts b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/transport.ts new file mode 100644 index 00000000000..6b9a4acae98 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/.modules/transport.ts @@ -0,0 +1,557 @@ +// transport.ts - ATS transport layer: RPC, Handle, errors, callbacks +import * as net from 'net'; +import * as rpc from 'vscode-jsonrpc/node.js'; + +// ============================================================================ +// Base Types +// ============================================================================ + +/** + * Type for callback functions that can be registered and invoked from .NET. + * Internal: receives args and client for handle wrapping. + */ +export type CallbackFunction = (args: unknown, client: AspireClient) => unknown | Promise; + +/** + * Represents a handle to a .NET object in the ATS system. + * Handles are typed references that can be passed between capabilities. + */ +export interface MarshalledHandle { + /** The handle ID (instance number) */ + $handle: string; + /** The ATS type ID */ + $type: string; +} + +/** + * Error details for ATS errors. + */ +export interface AtsErrorDetails { + /** The parameter that caused the error */ + parameter?: string; + /** The expected type or value */ + expected?: string; + /** The actual type or value */ + actual?: string; +} + +/** + * Structured error from ATS capability invocation. + */ +export interface AtsError { + /** Machine-readable error code */ + code: string; + /** Human-readable error message */ + message: string; + /** The capability that failed (if applicable) */ + capability?: string; + /** Additional error details */ + details?: AtsErrorDetails; +} + +/** + * ATS error codes returned by the server. + */ +export const AtsErrorCodes = { + /** Unknown capability ID */ + CapabilityNotFound: 'CAPABILITY_NOT_FOUND', + /** Handle ID doesn't exist or was disposed */ + HandleNotFound: 'HANDLE_NOT_FOUND', + /** Handle type doesn't satisfy capability's type constraint */ + TypeMismatch: 'TYPE_MISMATCH', + /** Missing required argument or wrong type */ + InvalidArgument: 'INVALID_ARGUMENT', + /** Argument value outside valid range */ + ArgumentOutOfRange: 'ARGUMENT_OUT_OF_RANGE', + /** Error occurred during callback invocation */ + CallbackError: 'CALLBACK_ERROR', + /** Unexpected error in capability execution */ + InternalError: 'INTERNAL_ERROR', +} as const; + +/** + * Type guard to check if a value is an ATS error response. + */ +export function isAtsError(value: unknown): value is { $error: AtsError } { + return ( + value !== null && + typeof value === 'object' && + '$error' in value && + typeof (value as { $error: unknown }).$error === 'object' + ); +} + +/** + * Type guard to check if a value is a marshalled handle. + */ +export function isMarshalledHandle(value: unknown): value is MarshalledHandle { + return ( + value !== null && + typeof value === 'object' && + '$handle' in value && + '$type' in value + ); +} + +// ============================================================================ +// Handle +// ============================================================================ + +/** + * A typed handle to a .NET object in the ATS system. + * Handles are opaque references that can be passed to capabilities. + * + * @typeParam T - The ATS type ID (e.g., "Aspire.Hosting/IDistributedApplicationBuilder") + */ +export class Handle { + private readonly _handleId: string; + private readonly _typeId: T; + + constructor(marshalled: MarshalledHandle) { + this._handleId = marshalled.$handle; + this._typeId = marshalled.$type as T; + } + + /** The handle ID (instance number) */ + get $handle(): string { + return this._handleId; + } + + /** The ATS type ID */ + get $type(): T { + return this._typeId; + } + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { + return { + $handle: this._handleId, + $type: this._typeId + }; + } + + /** String representation for debugging */ + toString(): string { + return `Handle<${this._typeId}>(${this._handleId})`; + } +} + +// ============================================================================ +// Handle Wrapper Registry +// ============================================================================ + +/** + * Factory function for creating typed wrapper instances from handles. + */ +export type HandleWrapperFactory = (handle: Handle, client: AspireClient) => unknown; + +/** + * Registry of handle wrapper factories by type ID. + * Generated code registers wrapper classes here so callback handles can be properly typed. + */ +const handleWrapperRegistry = new Map(); + +/** + * Register a wrapper factory for a type ID. + * Called by generated code to register wrapper classes. + */ +export function registerHandleWrapper(typeId: string, factory: HandleWrapperFactory): void { + handleWrapperRegistry.set(typeId, factory); +} + +/** + * Checks if a value is a marshalled handle and wraps it appropriately. + * Uses the wrapper registry to create typed wrapper instances when available. + * + * @param value - The value to potentially wrap + * @param client - Optional client for creating typed wrapper instances + */ +export function wrapIfHandle(value: unknown, client?: AspireClient): unknown { + if (value && typeof value === 'object') { + if (isMarshalledHandle(value)) { + const handle = new Handle(value); + const typeId = value.$type; + + // Try to find a registered wrapper factory for this type + if (typeId && client) { + const factory = handleWrapperRegistry.get(typeId); + if (factory) { + return factory(handle, client); + } + } + + return handle; + } + } + return value; +} + +// ============================================================================ +// Capability Error +// ============================================================================ + +/** + * Error thrown when an ATS capability invocation fails. + */ +export class CapabilityError extends Error { + constructor( + /** The structured error from the server */ + public readonly error: AtsError + ) { + super(error.message); + this.name = 'CapabilityError'; + } + + /** Machine-readable error code */ + get code(): string { + return this.error.code; + } + + /** The capability that failed (if applicable) */ + get capability(): string | undefined { + return this.error.capability; + } +} + +// ============================================================================ +// Callback Registry +// ============================================================================ + +const callbackRegistry = new Map(); +let callbackIdCounter = 0; + +/** + * Register a callback function that can be invoked from the .NET side. + * Returns a callback ID that should be passed to methods accepting callbacks. + * + * .NET passes arguments as an object with positional keys: `{ p0: value0, p1: value1, ... }` + * This function automatically extracts positional parameters and wraps handles. + * + * @example + * // Single parameter callback + * const id = registerCallback((ctx) => console.log(ctx)); + * // .NET sends: { p0: { $handle: "...", $type: "..." } } + * // Callback receives: Handle instance + * + * @example + * // Multi-parameter callback + * const id = registerCallback((a, b) => console.log(a, b)); + * // .NET sends: { p0: "hello", p1: 42 } + * // Callback receives: "hello", 42 + */ +export function registerCallback( + callback: (...args: any[]) => TResult | Promise +): string { + const callbackId = `callback_${++callbackIdCounter}_${Date.now()}`; + + // Wrap the callback to handle .NET's positional argument format + const wrapper: CallbackFunction = async (args: unknown, client: AspireClient) => { + // .NET sends args as object { p0: value0, p1: value1, ... } + if (args && typeof args === 'object' && !Array.isArray(args)) { + const argObj = args as Record; + const argArray: unknown[] = []; + + // Extract positional parameters (p0, p1, p2, ...) + for (let i = 0; ; i++) { + const key = `p${i}`; + if (key in argObj) { + argArray.push(wrapIfHandle(argObj[key], client)); + } else { + break; + } + } + + if (argArray.length > 0) { + // Spread positional arguments to callback + return await callback(...argArray); + } + + // No positional params found - call with no args + return await callback(); + } + + // Null/undefined - call with no args + if (args === null || args === undefined) { + return await callback(); + } + + // Primitive value - pass as single arg (shouldn't happen with current protocol) + return await callback(wrapIfHandle(args, client)); + }; + + callbackRegistry.set(callbackId, wrapper); + return callbackId; +} + +/** + * Unregister a callback by its ID. + */ +export function unregisterCallback(callbackId: string): boolean { + return callbackRegistry.delete(callbackId); +} + +/** + * Get the number of registered callbacks. + */ +export function getCallbackCount(): number { + return callbackRegistry.size; +} + +// ============================================================================ +// Cancellation Token Registry +// ============================================================================ + +/** + * Registry for cancellation tokens. + * Maps cancellation IDs to cleanup functions. + */ +const cancellationRegistry = new Map void>(); +let cancellationIdCounter = 0; + +/** + * A reference to the current AspireClient for sending cancel requests. + * Set by AspireClient.connect(). + */ +let currentClient: AspireClient | null = null; + +/** + * Register an AbortSignal for cancellation support. + * Returns a cancellation ID that should be passed to methods accepting CancellationToken. + * + * When the AbortSignal is aborted, sends a cancelToken request to the host. + * + * @param signal - The AbortSignal to register (optional) + * @returns The cancellation ID, or undefined if no signal provided + * + * @example + * const controller = new AbortController(); + * const id = registerCancellation(controller.signal); + * // Pass id to capability invocation + * // Later: controller.abort() will cancel the operation + */ +export function registerCancellation(signal?: AbortSignal): string | undefined { + if (!signal) { + return undefined; + } + + // Already aborted? Don't register + if (signal.aborted) { + return undefined; + } + + const cancellationId = `ct_${++cancellationIdCounter}_${Date.now()}`; + + // Set up the abort listener + const onAbort = () => { + // Send cancel request to host + if (currentClient?.connected) { + currentClient.cancelToken(cancellationId).catch(() => { + // Ignore errors - the operation may have already completed + }); + } + // Clean up the listener + cancellationRegistry.delete(cancellationId); + }; + + // Listen for abort + signal.addEventListener('abort', onAbort, { once: true }); + + // Store cleanup function + cancellationRegistry.set(cancellationId, () => { + signal.removeEventListener('abort', onAbort); + }); + + return cancellationId; +} + +/** + * Unregister a cancellation token by its ID. + * Call this when the operation completes to clean up resources. + * + * @param cancellationId - The cancellation ID to unregister + */ +export function unregisterCancellation(cancellationId: string | undefined): void { + if (!cancellationId) { + return; + } + + const cleanup = cancellationRegistry.get(cancellationId); + if (cleanup) { + cleanup(); + cancellationRegistry.delete(cancellationId); + } +} + +// ============================================================================ +// AspireClient (JSON-RPC Connection) +// ============================================================================ + +/** + * Client for connecting to the Aspire AppHost via socket/named pipe. + */ +export class AspireClient { + private connection: rpc.MessageConnection | null = null; + private socket: net.Socket | null = null; + private disconnectCallbacks: (() => void)[] = []; + private _pendingCalls = 0; + + constructor(private socketPath: string) { } + + /** + * Register a callback to be called when the connection is lost + */ + onDisconnect(callback: () => void): void { + this.disconnectCallbacks.push(callback); + } + + private notifyDisconnect(): void { + for (const callback of this.disconnectCallbacks) { + try { + callback(); + } catch { + // Ignore callback errors + } + } + } + + connect(timeoutMs: number = 5000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Connection timeout')), timeoutMs); + + // On Windows, use named pipes; on Unix, use Unix domain sockets + const isWindows = process.platform === 'win32'; + const pipePath = isWindows ? `\\\\.\\pipe\\${this.socketPath}` : this.socketPath; + + this.socket = net.createConnection(pipePath); + + this.socket.once('error', (error: Error) => { + clearTimeout(timeout); + reject(error); + }); + + this.socket.once('connect', () => { + clearTimeout(timeout); + try { + const reader = new rpc.SocketMessageReader(this.socket!); + const writer = new rpc.SocketMessageWriter(this.socket!); + this.connection = rpc.createMessageConnection(reader, writer); + + this.connection.onClose(() => { + this.connection = null; + this.notifyDisconnect(); + }); + this.connection.onError((err: any) => console.error('JsonRpc connection error:', err)); + + // Handle callback invocations from the .NET side + this.connection.onRequest('invokeCallback', async (callbackId: string, args: unknown) => { + const callback = callbackRegistry.get(callbackId); + if (!callback) { + throw new Error(`Callback not found: ${callbackId}`); + } + try { + // The registered wrapper handles arg unpacking and handle wrapping + // Pass this client so handles can be wrapped with typed wrapper classes + return await Promise.resolve(callback(args, this)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Callback execution failed: ${message}`); + } + }); + + this.connection.listen(); + + // Set the current client for cancellation registry + currentClient = this; + + resolve(); + } catch (e) { + reject(e); + } + }); + + this.socket.on('close', () => { + this.connection?.dispose(); + this.connection = null; + if (currentClient === this) { + currentClient = null; + } + this.notifyDisconnect(); + }); + }); + } + + ping(): Promise { + if (!this.connection) return Promise.reject(new Error('Not connected to AppHost')); + return this.connection.sendRequest('ping'); + } + + /** + * Cancel a CancellationToken by its ID. + * Called when an AbortSignal is aborted. + * + * @param tokenId - The token ID to cancel + * @returns True if the token was found and cancelled, false otherwise + */ + cancelToken(tokenId: string): Promise { + if (!this.connection) return Promise.reject(new Error('Not connected to AppHost')); + return this.connection.sendRequest('cancelToken', tokenId); + } + + /** + * Invoke an ATS capability by ID. + * + * Capabilities are operations exposed by [AspireExport] attributes. + * Results are automatically wrapped in Handle objects when applicable. + * + * @param capabilityId - The capability ID (e.g., "Aspire.Hosting/createBuilder") + * @param args - Arguments to pass to the capability + * @returns The capability result, wrapped as Handle if it's a handle type + * @throws CapabilityError if the capability fails + */ + async invokeCapability( + capabilityId: string, + args?: Record + ): Promise { + if (!this.connection) { + throw new Error('Not connected to AppHost'); + } + + // Ref counting: The vscode-jsonrpc socket keeps Node's event loop alive. + // We ref() during RPC calls so the process doesn't exit mid-call, and + // unref() when idle so the process can exit naturally after all work completes. + if (this._pendingCalls === 0) { + this.socket?.ref(); + } + this._pendingCalls++; + + try { + const result = await this.connection.sendRequest( + 'invokeCapability', + capabilityId, + args ?? null + ); + + // Check for structured error response + if (isAtsError(result)) { + throw new CapabilityError(result.$error); + } + + // Wrap handles automatically + return wrapIfHandle(result, this) as T; + } finally { + this._pendingCalls--; + if (this._pendingCalls === 0) { + this.socket?.unref(); + } + } + } + + disconnect(): void { + try { this.connection?.dispose(); } finally { this.connection = null; } + try { this.socket?.end(); } finally { this.socket = null; } + } + + get connected(): boolean { + return this.connection !== null && this.socket !== null; + } +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/apphost.run.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/apphost.run.json new file mode 100644 index 00000000000..2c361f7f93d --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/apphost.run.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "https": { + "applicationUrl": "https://localhost:49511;http://localhost:51415", + "environmentVariables": { + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:48887", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:33229" + } + } + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/apphost.ts b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/apphost.ts new file mode 100644 index 00000000000..d0005c68846 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/apphost.ts @@ -0,0 +1,68 @@ +// Aspire TypeScript AppHost - Validation for Aspire.Hosting.Azure.AppContainers +// For more information, see: https://aspire.dev + +import { createBuilder } from './.modules/aspire.js'; + +const builder = await createBuilder(); + +// === Azure Container App Environment === +// Test addAzureContainerAppEnvironment factory method +const env = builder.addAzureContainerAppEnvironment("myenv"); + +// Test fluent chaining on AzureContainerAppEnvironmentResource +await env + .withAzdResourceNaming() + .withCompactResourceNaming() + .withDashboard({ enable: true }) + .withHttpsUpgrade({ upgrade: false }); + +// Test withDashboard with no args (uses default) +const env2 = builder.addAzureContainerAppEnvironment("myenv2"); +await env2.withDashboard(); + +// Test withHttpsUpgrade with no args (uses default) +await env2.withHttpsUpgrade(); + +// === WithAzureLogAnalyticsWorkspace === +// Test withAzureLogAnalyticsWorkspace with a Log Analytics Workspace resource +const laws = await builder.addAzureLogAnalyticsWorkspace("laws"); +const env3 = builder.addAzureContainerAppEnvironment("myenv3"); +await env3.withAzureLogAnalyticsWorkspace(laws); + +// === PublishAsAzureContainerApp === +// Test publishAsAzureContainerApp on a container resource with callback +const web = builder.addContainer("web", "myregistry/web:latest"); +await web.publishAsAzureContainerApp(async (infrastructure, app) => { + // Configure container app via callback +}); + +// Test publishAsAzureContainerApp on an executable resource +const api = builder.addExecutable("api", "dotnet", ".", ["run"]); +await api.publishAsAzureContainerApp(async (infrastructure, app) => { + // Configure container app for executable +}); + +// === PublishAsAzureContainerAppJob === +// Test publishAsAzureContainerAppJob (parameterless - manual trigger) +const worker = builder.addContainer("worker", "myregistry/worker:latest"); +await worker.publishAsAzureContainerAppJob(); + +// Test publishAsConfiguredAzureContainerAppJob (with callback) +const processor = builder.addContainer("processor", "myregistry/processor:latest"); +await processor.publishAsConfiguredAzureContainerAppJob(async (infrastructure, job) => { + // Configure the container app job here +}); + +// Test publishAsScheduledAzureContainerAppJob (simple - no callback) +const scheduler = builder.addContainer("scheduler", "myregistry/scheduler:latest"); +await scheduler.publishAsScheduledAzureContainerAppJob("0 0 * * *"); + +// Test publishAsConfiguredScheduledAzureContainerAppJob (with callback) +const reporter = builder.addContainer("reporter", "myregistry/reporter:latest"); +await reporter.publishAsConfiguredScheduledAzureContainerAppJob("0 */6 * * *", { + configure: async (infrastructure, job) => { + // Configure the scheduled job here + } +}); + +await builder.build().run(); \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/package-lock.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/package-lock.json new file mode 100644 index 00000000000..2679fa17689 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/package-lock.json @@ -0,0 +1,962 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validationapphost", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/package.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/package.json new file mode 100644 index 00000000000..be16934198a --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/package.json @@ -0,0 +1,19 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "aspire run", + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/tsconfig.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/tsconfig.json new file mode 100644 index 00000000000..edf7302cc25 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.AppContainers/ValidationAppHost/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["apphost.ts", ".modules/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.AppContainers/AtsTypeMappings.cs b/src/Aspire.Hosting.Azure.AppContainers/AtsTypeMappings.cs new file mode 100644 index 00000000000..a95f710ee5e --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppContainers/AtsTypeMappings.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; +using Azure.Provisioning.AppContainers; + +// ============================================================================ +// ATS Type Exports for Aspire.Hosting.Azure.AppContainers +// ============================================================================ +// These assembly-level attributes mark external types as ATS exports so they +// can be used as callback context parameters in polyglot app hosts. + +[assembly: AspireExport(typeof(ContainerApp), ExposeProperties = true)] +[assembly: AspireExport(typeof(ContainerAppJob), ExposeProperties = true)] diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppContainerExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppContainerExtensions.cs index b104ea13212..611f5f1041c 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppContainerExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppContainerExtensions.cs @@ -31,6 +31,7 @@ public static class AzureContainerAppContainerExtensions /// /// /// + [AspireExport("publishContainerAsAzureContainerApp", MethodName = "publishAsAzureContainerApp", Description = "Configures the container resource to be published as an Azure Container App")] public static IResourceBuilder PublishAsAzureContainerApp(this IResourceBuilder container, Action configure) where T : ContainerResource { diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExecutableExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExecutableExtensions.cs index f9488bb0408..7d16b796941 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExecutableExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExecutableExtensions.cs @@ -31,6 +31,7 @@ public static class AzureContainerAppExecutableExtensions /// /// /// + [AspireExport("publishExecutableAsAzureContainerApp", MethodName = "publishAsAzureContainerApp", Description = "Configures the executable resource to be published as an Azure Container App")] public static IResourceBuilder PublishAsAzureContainerApp(this IResourceBuilder executable, Action configure) where T : ExecutableResource { diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index e9352549ffd..d3276388db7 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -56,6 +56,7 @@ internal static IDistributedApplicationBuilder AddAzureContainerAppsInfrastructu /// The distributed application builder. /// The name of the resource. /// + [AspireExport("addAzureContainerAppEnvironment", Description = "Adds an Azure Container App Environment resource")] public static IResourceBuilder AddAzureContainerAppEnvironment(this IDistributedApplicationBuilder builder, string name) { builder.AddAzureContainerAppsInfrastructureCore(); @@ -409,6 +410,7 @@ public static IResourceBuilder AddAzureCon /// This method allows for reusing the previously deployed resources if the application was deployed using /// azd without calling /// + [AspireExport("withAzdResourceNaming", Description = "Configures resources to use azd naming conventions")] public static IResourceBuilder WithAzdResourceNaming(this IResourceBuilder builder) { builder.Resource.UseAzdNamingConvention = true; @@ -438,6 +440,7 @@ public static IResourceBuilder WithAzdReso /// Use to change those names as well. /// /// + [AspireExport("withCompactResourceNaming", Description = "Configures resources to use compact naming for length-constrained Azure resources")] [Experimental("ASPIREACANAMING001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static IResourceBuilder WithCompactResourceNaming(this IResourceBuilder builder) { @@ -451,6 +454,7 @@ public static IResourceBuilder WithCompact /// The AzureContainerAppEnvironmentResource to configure. /// Whether to include the Aspire dashboard. Default is true. /// + [AspireExport("withDashboard", Description = "Configures whether the Aspire dashboard is included in the container app environment")] public static IResourceBuilder WithDashboard(this IResourceBuilder builder, bool enable = true) { builder.Resource.EnableDashboard = enable; @@ -469,6 +473,7 @@ public static IResourceBuilder WithDashboa /// Note that explicit ports specified for development (e.g., port 8080) are still normalized /// to standard ports (80/443) as required by Azure Container Apps. /// + [AspireExport("withHttpsUpgrade", Description = "Configures whether HTTP endpoints are upgraded to HTTPS")] public static IResourceBuilder WithHttpsUpgrade(this IResourceBuilder builder, bool upgrade = true) { builder.Resource.PreserveHttpEndpoints = !upgrade; @@ -482,6 +487,7 @@ public static IResourceBuilder WithHttpsUp /// The resource builder for the to use. /// /// Thrown when or is null. + [AspireExport("withAzureLogAnalyticsWorkspace", Description = "Configures the container app environment to use a specific Log Analytics Workspace")] public static IResourceBuilder WithAzureLogAnalyticsWorkspace(this IResourceBuilder builder, IResourceBuilder workspaceBuilder) { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppProjectExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppProjectExtensions.cs index ff00e3a8650..8e05d88f337 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppProjectExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppProjectExtensions.cs @@ -31,6 +31,7 @@ public static class AzureContainerAppProjectExtensions /// /// /// + [AspireExport("publishProjectAsAzureContainerApp", MethodName = "publishAsAzureContainerApp", Description = "Configures the project resource to be published as an Azure Container App")] public static IResourceBuilder PublishAsAzureContainerApp(this IResourceBuilder project, Action configure) where T : ProjectResource { diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs index 6a1877ebef6..6834d8a1ec6 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs @@ -53,7 +53,9 @@ public static class ContainerAppExtensions /// }); /// /// + /// This method is not available in polyglot app hosts. /// + [AspireExportIgnore(Reason = "Extends ContainerApp (Azure.Provisioning type) which is not an IResourceBuilder target, so the ATS codegen cannot generate a wrapper class for it.")] [Experimental("ASPIREACADOMAINS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder customDomain, IResourceBuilder certificateName) { @@ -147,7 +149,9 @@ public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder /// }); /// /// + /// This overload allows custom configuration of the container app job via a callback. /// + [AspireExport("publishAsConfiguredAzureContainerAppJob", Description = "Configures the compute resource as an Azure Container App Job with custom configuration")] [Experimental("ASPIREAZURE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static IResourceBuilder PublishAsAzureContainerAppJob(this IResourceBuilder resource, Action configure) where T : IComputeResource @@ -185,6 +189,7 @@ public static IResourceBuilder PublishAsAzureContainerAppJob(this IResourc /// /// /// + [AspireExport("publishAsAzureContainerAppJob", Description = "Configures the compute resource as a manually triggered Azure Container App Job")] [Experimental("ASPIREAZURE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static IResourceBuilder PublishAsAzureContainerAppJob(this IResourceBuilder resource) where T : IComputeResource @@ -216,7 +221,9 @@ public static IResourceBuilder PublishAsAzureContainerAppJob(this IResourc /// .PublishAsScheduledAzureContainerAppJob("0 0 * * *"); // Run every day at midnight /// /// + /// This overload allows custom configuration of the scheduled container app job via a callback. /// + [AspireExport("publishAsConfiguredScheduledAzureContainerAppJob", Description = "Configures the compute resource as a scheduled Azure Container App Job with custom configuration")] [Experimental("ASPIREAZURE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static IResourceBuilder PublishAsScheduledAzureContainerAppJob(this IResourceBuilder resource, string cronExpression, Action? configure = null) where T : IComputeResource @@ -232,4 +239,22 @@ public static IResourceBuilder PublishAsScheduledAzureContainerAppJob(this configure?.Invoke(infrastructure, job); }); } + + /// + /// Configures the specified compute resource as a scheduled Azure Container App Job with the provided cron expression. + /// + /// The type of the compute resource. + /// The compute resource builder. + /// The cron expression that defines the schedule for the job. + /// The updated compute resource builder. + /// + /// This method is a convenience wrapper that configures the job with a schedule trigger using the specified cron expression. + /// + [AspireExport("publishAsScheduledAzureContainerAppJob", Description = "Configures the compute resource as a scheduled Azure Container App Job")] + [Experimental("ASPIREAZURE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder PublishAsScheduledAzureContainerAppJob(this IResourceBuilder resource, string cronExpression) + where T : IComputeResource + { + return resource.PublishAsScheduledAzureContainerAppJob(cronExpression, configure: null); + } } diff --git a/src/Aspire.Hosting.Azure/AzureResourceInfrastructure.cs b/src/Aspire.Hosting.Azure/AzureResourceInfrastructure.cs index 5396171d0ca..381ce02d66f 100644 --- a/src/Aspire.Hosting.Azure/AzureResourceInfrastructure.cs +++ b/src/Aspire.Hosting.Azure/AzureResourceInfrastructure.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Azure; /// /// An Azure Provisioning which represents the root Bicep module that is generated for an Azure resource. /// +[AspireExport(ExposeProperties = true)] public sealed class AzureResourceInfrastructure : Infrastructure { internal AzureResourceInfrastructure(AzureProvisioningResource resource, string name) : base(name) diff --git a/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs index e41233e868a..d6372258a5e 100644 --- a/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs @@ -534,8 +534,9 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) var handleTypeIds = new HashSet(StringComparer.Ordinal); foreach (var handleType in context.HandleTypes) { - // Skip ReferenceExpression - it's defined in base.go - if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId) + // Skip ReferenceExpression and CancellationToken - they're defined in base.go/transport.go + if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId + || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; } @@ -705,7 +706,11 @@ AtsConstants.DateTime or AtsConstants.DateTimeOffset or }; private static bool IsCancellationToken(AtsParameterInfo parameter) => - parameter.Type?.TypeId == AtsConstants.CancellationToken; + IsCancellationTokenTypeId(parameter.Type?.TypeId); + + private static bool IsCancellationTokenTypeId(string? typeId) => + string.Equals(typeId, AtsConstants.CancellationToken, StringComparison.Ordinal) + || (typeId?.EndsWith("/System.Threading.CancellationToken", StringComparison.Ordinal) ?? false); private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsTypeRef? typeRef) { @@ -714,8 +719,9 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression - it's defined in base.go - if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId) + // Skip ReferenceExpression and CancellationToken - they're defined in base.go/transport.go + if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId + || IsCancellationTokenTypeId(typeRef.TypeId)) { return; } diff --git a/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs index 050a449bc0f..a69814b8ef7 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs @@ -501,8 +501,9 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) var handleTypeIds = new HashSet(StringComparer.Ordinal); foreach (var handleType in context.HandleTypes) { - // Skip ReferenceExpression - it's defined in Base.java - if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId) + // Skip ReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java + if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId + || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; } @@ -669,7 +670,11 @@ AtsConstants.DateTime or AtsConstants.DateTimeOffset or }; private static bool IsCancellationToken(AtsParameterInfo parameter) => - parameter.Type?.TypeId == AtsConstants.CancellationToken; + IsCancellationTokenTypeId(parameter.Type?.TypeId); + + private static bool IsCancellationTokenTypeId(string? typeId) => + string.Equals(typeId, AtsConstants.CancellationToken, StringComparison.Ordinal) + || (typeId?.EndsWith("/System.Threading.CancellationToken", StringComparison.Ordinal) ?? false); private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsTypeRef? typeRef) { @@ -678,8 +683,9 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression - it's defined in Base.java - if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId) + // Skip ReferenceExpression and CancellationToken - they're defined in Base.java/Transport.java + if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId + || IsCancellationTokenTypeId(typeRef.TypeId)) { return; } diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs index 5f5f08e6f0f..32cd3090232 100644 --- a/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs @@ -568,8 +568,9 @@ private IReadOnlyList BuildHandleTypes(AtsContext context) var handleTypeIds = new HashSet(StringComparer.Ordinal); foreach (var handleType in context.HandleTypes) { - // Skip ReferenceExpression - it's defined in base.rs - if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId) + // Skip ReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs + if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId + || IsCancellationTokenTypeId(handleType.AtsTypeId)) { continue; } @@ -804,10 +805,15 @@ AtsConstants.DateTime or AtsConstants.DateTimeOffset or private static bool IsHandleType(AtsTypeRef? typeRef) => typeRef?.Category == AtsTypeCategory.Handle - && typeRef.TypeId != AtsConstants.ReferenceExpressionTypeId; + && typeRef.TypeId != AtsConstants.ReferenceExpressionTypeId + && !IsCancellationTokenTypeId(typeRef.TypeId); private static bool IsCancellationToken(AtsParameterInfo parameter) => - parameter.Type?.TypeId == AtsConstants.CancellationToken; + IsCancellationTokenTypeId(parameter.Type?.TypeId); + + private static bool IsCancellationTokenTypeId(string? typeId) => + string.Equals(typeId, AtsConstants.CancellationToken, StringComparison.Ordinal) + || (typeId?.EndsWith("/System.Threading.CancellationToken", StringComparison.Ordinal) ?? false); private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsTypeRef? typeRef) { @@ -816,8 +822,9 @@ private static void AddHandleTypeIfNeeded(HashSet handleTypeIds, AtsType return; } - // Skip ReferenceExpression - it's defined in base.rs - if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId) + // Skip ReferenceExpression and CancellationToken - they're defined in base.rs/transport.rs + if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId + || IsCancellationTokenTypeId(typeRef.TypeId)) { return; } diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs index c38b2b3df79..e53bda49992 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs @@ -1320,9 +1320,9 @@ private void GenerateCallbackBody(AtsParameterInfo callbackParam, IReadOnlyList< { // Multi-parameter callback - .NET sends as { p0, p1, ... } var paramNames = callbackParameters.Select((p, i) => $"p{i}").ToList(); - var destructure = string.Join(", ", paramNames); + var destructureWithTypes = string.Join(", ", paramNames.Select(p => $"{p}: unknown")); - WriteLine($" const args = argsData as {{ {destructure}: unknown }};"); + WriteLine($" const args = argsData as {{ {destructureWithTypes} }};"); var callArgs = new List(); for (var i = 0; i < callbackParameters.Count; i++) diff --git a/src/Aspire.Hosting/Ats/AtsCapabilityScanner.cs b/src/Aspire.Hosting/Ats/AtsCapabilityScanner.cs index 9e51355c23c..39f44534211 100644 --- a/src/Aspire.Hosting/Ats/AtsCapabilityScanner.cs +++ b/src/Aspire.Hosting/Ats/AtsCapabilityScanner.cs @@ -270,6 +270,42 @@ private static ScanResult ScanAssemblyWithoutExpansion( types = ex.Types.Where(t => t != null).ToArray()!; } + // Process assembly-level [AspireExport(typeof(T))] attributes for cross-assembly type exports + // This enables exporting types from external assemblies (e.g., Azure.Provisioning types) + foreach (var assemblyExportAttr in assembly.GetCustomAttributes()) + { + if (assemblyExportAttr.Type is null) + { + continue; + } + + var exportedType = assemblyExportAttr.Type; + + // Register the type info for the exported type + var typeInfo = CreateTypeInfo(exportedType, assemblyExportAttr); + if (typeInfo != null) + { + typeInfos.Add(typeInfo); + } + + // If ExposeProperties or ExposeMethods, create context type capabilities + if (assemblyExportAttr.ExposeProperties || assemblyExportAttr.ExposeMethods) + { + var contextResult = CreateContextTypeCapabilities(exportedType, assemblyName); + capabilities.AddRange(contextResult.Capabilities); + diagnostics.AddRange(contextResult.Diagnostics); + + foreach (var (id, method) in contextResult.Methods) + { + methods[id] = method; + } + foreach (var (id, property) in contextResult.Properties) + { + properties[id] = property; + } + } + } + foreach (var type in types) { // Check for [AspireDto] attribute - scan DTO types for code generation @@ -522,6 +558,19 @@ private static void ResolveUnknownTypes( foreach (var param in capability.Parameters) { ResolveTypeRef(param.Type, validTypes); + + // Also resolve callback parameter types and return type + if (param.IsCallback) + { + if (param.CallbackParameters != null) + { + foreach (var cbParam in param.CallbackParameters) + { + ResolveTypeRef(cbParam.Type, validTypes); + } + } + ResolveTypeRef(param.CallbackReturnType, validTypes); + } } } } diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go index 3f4614c687e..51e2d384f81 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go @@ -941,6 +941,21 @@ func (s *TestRedisResource) WaitForReadyAsync(timeout float64, cancellationToken return result.(*bool), nil } +// WithMultiParamHandleCallback tests multi-param callback destructuring +func (s *TestRedisResource) WithMultiParamHandleCallback(callback func(...any) any) (*TestRedisResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withMultiParamHandleCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*TestRedisResource), nil +} + // TestResourceContext wraps a handle for Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext. type TestResourceContext struct { HandleWrapperBase diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index e4ef1ad0d66..866433c963f 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -2103,6 +2103,44 @@ func (s *ExecuteCommandContext) SetCancellationToken(value *CancellationToken) ( return result.(*ExecuteCommandContext), nil } +// IConfiguration wraps a handle for Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfiguration. +type IConfiguration struct { + HandleWrapperBase +} + +// NewIConfiguration creates a new IConfiguration. +func NewIConfiguration(handle *Handle, client *AspireClient) *IConfiguration { + return &IConfiguration{ + HandleWrapperBase: NewHandleWrapperBase(handle, client), + } +} + +// GetConfigValue gets a configuration value by key +func (s *IConfiguration) GetConfigValue(key string) (*string, error) { + reqArgs := map[string]any{ + "configuration": SerializeValue(s.Handle()), + } + reqArgs["key"] = SerializeValue(key) + result, err := s.Client().InvokeCapability("Aspire.Hosting/getConfigValue", reqArgs) + if err != nil { + return nil, err + } + return result.(*string), nil +} + +// GetConnectionString gets a connection string by name +func (s *IConfiguration) GetConnectionString(name string) (*string, error) { + reqArgs := map[string]any{ + "configuration": SerializeValue(s.Handle()), + } + reqArgs["name"] = SerializeValue(name) + result, err := s.Client().InvokeCapability("Aspire.Hosting/getConnectionString", reqArgs) + if err != nil { + return nil, err + } + return result.(*string), nil +} + // IDistributedApplicationBuilder wraps a handle for Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder. type IDistributedApplicationBuilder struct { HandleWrapperBase @@ -2309,6 +2347,42 @@ func NewIDistributedApplicationResourceEvent(handle *Handle, client *AspireClien } } +// IHostEnvironment wraps a handle for Microsoft.Extensions.Hosting.Abstractions/Microsoft.Extensions.Hosting.IHostEnvironment. +type IHostEnvironment struct { + HandleWrapperBase +} + +// NewIHostEnvironment creates a new IHostEnvironment. +func NewIHostEnvironment(handle *Handle, client *AspireClient) *IHostEnvironment { + return &IHostEnvironment{ + HandleWrapperBase: NewHandleWrapperBase(handle, client), + } +} + +// GetEnvironmentName gets the environment name +func (s *IHostEnvironment) GetEnvironmentName() (*string, error) { + reqArgs := map[string]any{ + "environment": SerializeValue(s.Handle()), + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/getEnvironmentName", reqArgs) + if err != nil { + return nil, err + } + return result.(*string), nil +} + +// IsDevelopment checks if running in Development environment +func (s *IHostEnvironment) IsDevelopment() (*bool, error) { + reqArgs := map[string]any{ + "environment": SerializeValue(s.Handle()), + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/isDevelopment", reqArgs) + if err != nil { + return nil, err + } + return result.(*bool), nil +} + // IResource wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource. type IResource struct { ResourceBuilderBase @@ -2323,13 +2397,13 @@ func NewIResource(handle *Handle, client *AspireClient) *IResource { // IResourceWithArgs wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs. type IResourceWithArgs struct { - HandleWrapperBase + ResourceBuilderBase } // NewIResourceWithArgs creates a new IResourceWithArgs. func NewIResourceWithArgs(handle *Handle, client *AspireClient) *IResourceWithArgs { return &IResourceWithArgs{ - HandleWrapperBase: NewHandleWrapperBase(handle, client), + ResourceBuilderBase: NewResourceBuilderBase(handle, client), } } @@ -2347,25 +2421,37 @@ func NewIResourceWithConnectionString(handle *Handle, client *AspireClient) *IRe // IResourceWithEndpoints wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints. type IResourceWithEndpoints struct { - HandleWrapperBase + ResourceBuilderBase } // NewIResourceWithEndpoints creates a new IResourceWithEndpoints. func NewIResourceWithEndpoints(handle *Handle, client *AspireClient) *IResourceWithEndpoints { return &IResourceWithEndpoints{ - HandleWrapperBase: NewHandleWrapperBase(handle, client), + ResourceBuilderBase: NewResourceBuilderBase(handle, client), } } // IResourceWithEnvironment wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment. type IResourceWithEnvironment struct { - HandleWrapperBase + ResourceBuilderBase } // NewIResourceWithEnvironment creates a new IResourceWithEnvironment. func NewIResourceWithEnvironment(handle *Handle, client *AspireClient) *IResourceWithEnvironment { return &IResourceWithEnvironment{ - HandleWrapperBase: NewHandleWrapperBase(handle, client), + ResourceBuilderBase: NewResourceBuilderBase(handle, client), + } +} + +// IResourceWithParent wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithParent. +type IResourceWithParent struct { + ResourceBuilderBase +} + +// NewIResourceWithParent creates a new IResourceWithParent. +func NewIResourceWithParent(handle *Handle, client *AspireClient) *IResourceWithParent { + return &IResourceWithParent{ + ResourceBuilderBase: NewResourceBuilderBase(handle, client), } } @@ -2383,16 +2469,54 @@ func NewIResourceWithServiceDiscovery(handle *Handle, client *AspireClient) *IRe // IResourceWithWaitSupport wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport. type IResourceWithWaitSupport struct { - HandleWrapperBase + ResourceBuilderBase } // NewIResourceWithWaitSupport creates a new IResourceWithWaitSupport. func NewIResourceWithWaitSupport(handle *Handle, client *AspireClient) *IResourceWithWaitSupport { return &IResourceWithWaitSupport{ + ResourceBuilderBase: NewResourceBuilderBase(handle, client), + } +} + +// IServiceProvider wraps a handle for System.ComponentModel/System.IServiceProvider. +type IServiceProvider struct { + HandleWrapperBase +} + +// NewIServiceProvider creates a new IServiceProvider. +func NewIServiceProvider(handle *Handle, client *AspireClient) *IServiceProvider { + return &IServiceProvider{ HandleWrapperBase: NewHandleWrapperBase(handle, client), } } +// GetService gets a service by ATS type ID +func (s *IServiceProvider) GetService(typeId string) (any, error) { + reqArgs := map[string]any{ + "serviceProvider": SerializeValue(s.Handle()), + } + reqArgs["typeId"] = SerializeValue(typeId) + result, err := s.Client().InvokeCapability("Aspire.Hosting/getService", reqArgs) + if err != nil { + return nil, err + } + return result, nil +} + +// GetRequiredService gets a required service by ATS type ID +func (s *IServiceProvider) GetRequiredService(typeId string) (any, error) { + reqArgs := map[string]any{ + "serviceProvider": SerializeValue(s.Handle()), + } + reqArgs["typeId"] = SerializeValue(typeId) + result, err := s.Client().InvokeCapability("Aspire.Hosting/getRequiredService", reqArgs) + if err != nil { + return nil, err + } + return result, nil +} + // ITestVaultResource wraps a handle for Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource. type ITestVaultResource struct { ResourceBuilderBase @@ -3402,6 +3526,123 @@ func (s *ProjectResource) WithCancellableOperation(operation func(...any) any) ( return result.(*IResource), nil } +// ResourceLoggerService wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceLoggerService. +type ResourceLoggerService struct { + HandleWrapperBase +} + +// NewResourceLoggerService creates a new ResourceLoggerService. +func NewResourceLoggerService(handle *Handle, client *AspireClient) *ResourceLoggerService { + return &ResourceLoggerService{ + HandleWrapperBase: NewHandleWrapperBase(handle, client), + } +} + +// CompleteLog completes the log stream for a resource +func (s *ResourceLoggerService) CompleteLog(resource *IResource) error { + reqArgs := map[string]any{ + "loggerService": SerializeValue(s.Handle()), + } + reqArgs["resource"] = SerializeValue(resource) + _, err := s.Client().InvokeCapability("Aspire.Hosting/completeLog", reqArgs) + return err +} + +// CompleteLogByName completes the log stream by resource name +func (s *ResourceLoggerService) CompleteLogByName(resourceName string) error { + reqArgs := map[string]any{ + "loggerService": SerializeValue(s.Handle()), + } + reqArgs["resourceName"] = SerializeValue(resourceName) + _, err := s.Client().InvokeCapability("Aspire.Hosting/completeLogByName", reqArgs) + return err +} + +// ResourceNotificationService wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceNotificationService. +type ResourceNotificationService struct { + HandleWrapperBase +} + +// NewResourceNotificationService creates a new ResourceNotificationService. +func NewResourceNotificationService(handle *Handle, client *AspireClient) *ResourceNotificationService { + return &ResourceNotificationService{ + HandleWrapperBase: NewHandleWrapperBase(handle, client), + } +} + +// WaitForResourceState waits for a resource to reach a specified state +func (s *ResourceNotificationService) WaitForResourceState(resourceName string, targetState string) error { + reqArgs := map[string]any{ + "notificationService": SerializeValue(s.Handle()), + } + reqArgs["resourceName"] = SerializeValue(resourceName) + reqArgs["targetState"] = SerializeValue(targetState) + _, err := s.Client().InvokeCapability("Aspire.Hosting/waitForResourceState", reqArgs) + return err +} + +// WaitForResourceStates waits for a resource to reach one of the specified states +func (s *ResourceNotificationService) WaitForResourceStates(resourceName string, targetStates []string) (*string, error) { + reqArgs := map[string]any{ + "notificationService": SerializeValue(s.Handle()), + } + reqArgs["resourceName"] = SerializeValue(resourceName) + reqArgs["targetStates"] = SerializeValue(targetStates) + result, err := s.Client().InvokeCapability("Aspire.Hosting/waitForResourceStates", reqArgs) + if err != nil { + return nil, err + } + return result.(*string), nil +} + +// WaitForResourceHealthy waits for a resource to become healthy +func (s *ResourceNotificationService) WaitForResourceHealthy(resourceName string) (*ResourceEventDto, error) { + reqArgs := map[string]any{ + "notificationService": SerializeValue(s.Handle()), + } + reqArgs["resourceName"] = SerializeValue(resourceName) + result, err := s.Client().InvokeCapability("Aspire.Hosting/waitForResourceHealthy", reqArgs) + if err != nil { + return nil, err + } + return result.(*ResourceEventDto), nil +} + +// WaitForDependencies waits for all dependencies of a resource to be ready +func (s *ResourceNotificationService) WaitForDependencies(resource *IResource) error { + reqArgs := map[string]any{ + "notificationService": SerializeValue(s.Handle()), + } + reqArgs["resource"] = SerializeValue(resource) + _, err := s.Client().InvokeCapability("Aspire.Hosting/waitForDependencies", reqArgs) + return err +} + +// TryGetResourceState tries to get the current state of a resource +func (s *ResourceNotificationService) TryGetResourceState(resourceName string) (*ResourceEventDto, error) { + reqArgs := map[string]any{ + "notificationService": SerializeValue(s.Handle()), + } + reqArgs["resourceName"] = SerializeValue(resourceName) + result, err := s.Client().InvokeCapability("Aspire.Hosting/tryGetResourceState", reqArgs) + if err != nil { + return nil, err + } + return result.(*ResourceEventDto), nil +} + +// PublishResourceUpdate publishes an update for a resource's state +func (s *ResourceNotificationService) PublishResourceUpdate(resource *IResource, state string, stateStyle string) error { + reqArgs := map[string]any{ + "notificationService": SerializeValue(s.Handle()), + } + reqArgs["resource"] = SerializeValue(resource) + reqArgs["state"] = SerializeValue(state) + reqArgs["stateStyle"] = SerializeValue(stateStyle) + _, err := s.Client().InvokeCapability("Aspire.Hosting/publishResourceUpdate", reqArgs) + return err +} + // ResourceUrlsCallbackContext wraps a handle for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext. type ResourceUrlsCallbackContext struct { HandleWrapperBase @@ -5326,6 +5567,21 @@ func (s *TestRedisResource) WaitForReadyAsync(timeout float64, cancellationToken return result.(*bool), nil } +// WithMultiParamHandleCallback tests multi-param callback destructuring +func (s *TestRedisResource) WithMultiParamHandleCallback(callback func(...any) any) (*TestRedisResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withMultiParamHandleCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*TestRedisResource), nil +} + // TestResourceContext wraps a handle for Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext. type TestResourceContext struct { HandleWrapperBase @@ -6225,21 +6481,72 @@ func NewUpdateCommandStateContext(handle *Handle, client *AspireClient) *UpdateC // ============================================================================ func init() { + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", func(h *Handle, c *AspireClient) any { + return NewIDistributedApplicationBuilder(h, c) + }) RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplication", func(h *Handle, c *AspireClient) any { return NewDistributedApplication(h, c) }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext", func(h *Handle, c *AspireClient) any { - return NewDistributedApplicationExecutionContext(h, c) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference", func(h *Handle, c *AspireClient) any { + return NewEndpointReference(h, c) }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContextOptions", func(h *Handle, c *AspireClient) any { - return NewDistributedApplicationExecutionContextOptions(h, c) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", func(h *Handle, c *AspireClient) any { + return NewIResource(h, c) }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", func(h *Handle, c *AspireClient) any { - return NewIDistributedApplicationBuilder(h, c) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", func(h *Handle, c *AspireClient) any { + return NewIResourceWithEnvironment(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", func(h *Handle, c *AspireClient) any { + return NewIResourceWithEndpoints(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs", func(h *Handle, c *AspireClient) any { + return NewIResourceWithArgs(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString", func(h *Handle, c *AspireClient) any { + return NewIResourceWithConnectionString(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport", func(h *Handle, c *AspireClient) any { + return NewIResourceWithWaitSupport(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithParent", func(h *Handle, c *AspireClient) any { + return NewIResourceWithParent(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource", func(h *Handle, c *AspireClient) any { + return NewContainerResource(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource", func(h *Handle, c *AspireClient) any { + return NewExecutableResource(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource", func(h *Handle, c *AspireClient) any { + return NewProjectResource(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource", func(h *Handle, c *AspireClient) any { + return NewParameterResource(h, c) + }) + RegisterHandleWrapper("System.ComponentModel/System.IServiceProvider", func(h *Handle, c *AspireClient) any { + return NewIServiceProvider(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceNotificationService", func(h *Handle, c *AspireClient) any { + return NewResourceNotificationService(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceLoggerService", func(h *Handle, c *AspireClient) any { + return NewResourceLoggerService(h, c) + }) + RegisterHandleWrapper("Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfiguration", func(h *Handle, c *AspireClient) any { + return NewIConfiguration(h, c) + }) + RegisterHandleWrapper("Microsoft.Extensions.Hosting.Abstractions/Microsoft.Extensions.Hosting.IHostEnvironment", func(h *Handle, c *AspireClient) any { + return NewIHostEnvironment(h, c) }) RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription", func(h *Handle, c *AspireClient) any { return NewDistributedApplicationEventSubscription(h, c) }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext", func(h *Handle, c *AspireClient) any { + return NewDistributedApplicationExecutionContext(h, c) + }) + RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContextOptions", func(h *Handle, c *AspireClient) any { + return NewDistributedApplicationExecutionContextOptions(h, c) + }) RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationResourceEventSubscription", func(h *Handle, c *AspireClient) any { return NewDistributedApplicationResourceEventSubscription(h, c) }) @@ -6255,9 +6562,6 @@ func init() { RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext", func(h *Handle, c *AspireClient) any { return NewCommandLineArgsCallbackContext(h, c) }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference", func(h *Handle, c *AspireClient) any { - return NewEndpointReference(h, c) - }) RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression", func(h *Handle, c *AspireClient) any { return NewEndpointReferenceExpression(h, c) }) @@ -6273,27 +6577,9 @@ func init() { RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext", func(h *Handle, c *AspireClient) any { return NewResourceUrlsCallbackContext(h, c) }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource", func(h *Handle, c *AspireClient) any { - return NewContainerResource(h, c) - }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource", func(h *Handle, c *AspireClient) any { - return NewExecutableResource(h, c) - }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource", func(h *Handle, c *AspireClient) any { - return NewParameterResource(h, c) - }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString", func(h *Handle, c *AspireClient) any { - return NewIResourceWithConnectionString(h, c) - }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource", func(h *Handle, c *AspireClient) any { - return NewProjectResource(h, c) - }) RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery", func(h *Handle, c *AspireClient) any { return NewIResourceWithServiceDiscovery(h, c) }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", func(h *Handle, c *AspireClient) any { - return NewIResource(h, c) - }) RegisterHandleWrapper("Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCallbackContext", func(h *Handle, c *AspireClient) any { return NewTestCallbackContext(h, c) }) @@ -6318,18 +6604,6 @@ func init() { RegisterHandleWrapper("Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource", func(h *Handle, c *AspireClient) any { return NewITestVaultResource(h, c) }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", func(h *Handle, c *AspireClient) any { - return NewIResourceWithEnvironment(h, c) - }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs", func(h *Handle, c *AspireClient) any { - return NewIResourceWithArgs(h, c) - }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", func(h *Handle, c *AspireClient) any { - return NewIResourceWithEndpoints(h, c) - }) - RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport", func(h *Handle, c *AspireClient) any { - return NewIResourceWithWaitSupport(h, c) - }) RegisterHandleWrapper("Aspire.Hosting/Dict", func(h *Handle, c *AspireClient) any { return &AspireDict[any, any]{HandleWrapperBase: NewHandleWrapperBase(h, c)} }) diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java index 9883e04c467..4e21a9a71c7 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java @@ -697,6 +697,16 @@ public boolean waitForReadyAsync(double timeout, CancellationToken cancellationT return (boolean) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/waitForReadyAsync", reqArgs); } + /** Tests multi-param callback destructuring */ + public TestRedisResource withMultiParamHandleCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (TestRedisResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withMultiParamHandleCallback", reqArgs); + } + } /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext. */ diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 70dbb8b9b64..94497b86860 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1756,6 +1756,30 @@ public ExecuteCommandContext setCancellationToken(CancellationToken value) { } +/** Wrapper for Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfiguration. */ +class IConfiguration extends HandleWrapperBase { + IConfiguration(Handle handle, AspireClient client) { + super(handle, client); + } + + /** Gets a configuration value by key */ + public String getConfigValue(String key) { + Map reqArgs = new HashMap<>(); + reqArgs.put("configuration", AspireClient.serializeValue(getHandle())); + reqArgs.put("key", AspireClient.serializeValue(key)); + return (String) getClient().invokeCapability("Aspire.Hosting/getConfigValue", reqArgs); + } + + /** Gets a connection string by name */ + public String getConnectionString(String name) { + Map reqArgs = new HashMap<>(); + reqArgs.put("configuration", AspireClient.serializeValue(getHandle())); + reqArgs.put("name", AspireClient.serializeValue(name)); + return (String) getClient().invokeCapability("Aspire.Hosting/getConnectionString", reqArgs); + } + +} + /** Wrapper for Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder. */ class IDistributedApplicationBuilder extends HandleWrapperBase { IDistributedApplicationBuilder(Handle handle, AspireClient client) { @@ -1895,6 +1919,28 @@ class IDistributedApplicationResourceEvent extends HandleWrapperBase { } +/** Wrapper for Microsoft.Extensions.Hosting.Abstractions/Microsoft.Extensions.Hosting.IHostEnvironment. */ +class IHostEnvironment extends HandleWrapperBase { + IHostEnvironment(Handle handle, AspireClient client) { + super(handle, client); + } + + /** Gets the environment name */ + public String getEnvironmentName() { + Map reqArgs = new HashMap<>(); + reqArgs.put("environment", AspireClient.serializeValue(getHandle())); + return (String) getClient().invokeCapability("Aspire.Hosting/getEnvironmentName", reqArgs); + } + + /** Checks if running in Development environment */ + public boolean isDevelopment() { + Map reqArgs = new HashMap<>(); + reqArgs.put("environment", AspireClient.serializeValue(getHandle())); + return (boolean) getClient().invokeCapability("Aspire.Hosting/isDevelopment", reqArgs); + } + +} + /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource. */ class IResource extends ResourceBuilderBase { IResource(Handle handle, AspireClient client) { @@ -1904,7 +1950,7 @@ class IResource extends ResourceBuilderBase { } /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs. */ -class IResourceWithArgs extends HandleWrapperBase { +class IResourceWithArgs extends ResourceBuilderBase { IResourceWithArgs(Handle handle, AspireClient client) { super(handle, client); } @@ -1920,7 +1966,7 @@ class IResourceWithConnectionString extends ResourceBuilderBase { } /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints. */ -class IResourceWithEndpoints extends HandleWrapperBase { +class IResourceWithEndpoints extends ResourceBuilderBase { IResourceWithEndpoints(Handle handle, AspireClient client) { super(handle, client); } @@ -1928,13 +1974,21 @@ class IResourceWithEndpoints extends HandleWrapperBase { } /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment. */ -class IResourceWithEnvironment extends HandleWrapperBase { +class IResourceWithEnvironment extends ResourceBuilderBase { IResourceWithEnvironment(Handle handle, AspireClient client) { super(handle, client); } } +/** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithParent. */ +class IResourceWithParent extends ResourceBuilderBase { + IResourceWithParent(Handle handle, AspireClient client) { + super(handle, client); + } + +} + /** Wrapper for Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery. */ class IResourceWithServiceDiscovery extends ResourceBuilderBase { IResourceWithServiceDiscovery(Handle handle, AspireClient client) { @@ -1944,13 +1998,37 @@ class IResourceWithServiceDiscovery extends ResourceBuilderBase { } /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport. */ -class IResourceWithWaitSupport extends HandleWrapperBase { +class IResourceWithWaitSupport extends ResourceBuilderBase { IResourceWithWaitSupport(Handle handle, AspireClient client) { super(handle, client); } } +/** Wrapper for System.ComponentModel/System.IServiceProvider. */ +class IServiceProvider extends HandleWrapperBase { + IServiceProvider(Handle handle, AspireClient client) { + super(handle, client); + } + + /** Gets a service by ATS type ID */ + public Object getService(String typeId) { + Map reqArgs = new HashMap<>(); + reqArgs.put("serviceProvider", AspireClient.serializeValue(getHandle())); + reqArgs.put("typeId", AspireClient.serializeValue(typeId)); + return (Object) getClient().invokeCapability("Aspire.Hosting/getService", reqArgs); + } + + /** Gets a required service by ATS type ID */ + public Object getRequiredService(String typeId) { + Map reqArgs = new HashMap<>(); + reqArgs.put("serviceProvider", AspireClient.serializeValue(getHandle())); + reqArgs.put("typeId", AspireClient.serializeValue(typeId)); + return (Object) getClient().invokeCapability("Aspire.Hosting/getRequiredService", reqArgs); + } + +} + /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource. */ class ITestVaultResource extends ResourceBuilderBase { ITestVaultResource(Handle handle, AspireClient client) { @@ -2669,6 +2747,96 @@ public IResource withCancellableOperation(Function operation) } +/** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceLoggerService. */ +class ResourceLoggerService extends HandleWrapperBase { + ResourceLoggerService(Handle handle, AspireClient client) { + super(handle, client); + } + + /** Completes the log stream for a resource */ + public void completeLog(IResource resource) { + Map reqArgs = new HashMap<>(); + reqArgs.put("loggerService", AspireClient.serializeValue(getHandle())); + reqArgs.put("resource", AspireClient.serializeValue(resource)); + getClient().invokeCapability("Aspire.Hosting/completeLog", reqArgs); + } + + /** Completes the log stream by resource name */ + public void completeLogByName(String resourceName) { + Map reqArgs = new HashMap<>(); + reqArgs.put("loggerService", AspireClient.serializeValue(getHandle())); + reqArgs.put("resourceName", AspireClient.serializeValue(resourceName)); + getClient().invokeCapability("Aspire.Hosting/completeLogByName", reqArgs); + } + +} + +/** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceNotificationService. */ +class ResourceNotificationService extends HandleWrapperBase { + ResourceNotificationService(Handle handle, AspireClient client) { + super(handle, client); + } + + /** Waits for a resource to reach a specified state */ + public void waitForResourceState(String resourceName, String targetState) { + Map reqArgs = new HashMap<>(); + reqArgs.put("notificationService", AspireClient.serializeValue(getHandle())); + reqArgs.put("resourceName", AspireClient.serializeValue(resourceName)); + if (targetState != null) { + reqArgs.put("targetState", AspireClient.serializeValue(targetState)); + } + getClient().invokeCapability("Aspire.Hosting/waitForResourceState", reqArgs); + } + + /** Waits for a resource to reach one of the specified states */ + public String waitForResourceStates(String resourceName, String[] targetStates) { + Map reqArgs = new HashMap<>(); + reqArgs.put("notificationService", AspireClient.serializeValue(getHandle())); + reqArgs.put("resourceName", AspireClient.serializeValue(resourceName)); + reqArgs.put("targetStates", AspireClient.serializeValue(targetStates)); + return (String) getClient().invokeCapability("Aspire.Hosting/waitForResourceStates", reqArgs); + } + + /** Waits for a resource to become healthy */ + public ResourceEventDto waitForResourceHealthy(String resourceName) { + Map reqArgs = new HashMap<>(); + reqArgs.put("notificationService", AspireClient.serializeValue(getHandle())); + reqArgs.put("resourceName", AspireClient.serializeValue(resourceName)); + return (ResourceEventDto) getClient().invokeCapability("Aspire.Hosting/waitForResourceHealthy", reqArgs); + } + + /** Waits for all dependencies of a resource to be ready */ + public void waitForDependencies(IResource resource) { + Map reqArgs = new HashMap<>(); + reqArgs.put("notificationService", AspireClient.serializeValue(getHandle())); + reqArgs.put("resource", AspireClient.serializeValue(resource)); + getClient().invokeCapability("Aspire.Hosting/waitForDependencies", reqArgs); + } + + /** Tries to get the current state of a resource */ + public ResourceEventDto tryGetResourceState(String resourceName) { + Map reqArgs = new HashMap<>(); + reqArgs.put("notificationService", AspireClient.serializeValue(getHandle())); + reqArgs.put("resourceName", AspireClient.serializeValue(resourceName)); + return (ResourceEventDto) getClient().invokeCapability("Aspire.Hosting/tryGetResourceState", reqArgs); + } + + /** Publishes an update for a resource's state */ + public void publishResourceUpdate(IResource resource, String state, String stateStyle) { + Map reqArgs = new HashMap<>(); + reqArgs.put("notificationService", AspireClient.serializeValue(getHandle())); + reqArgs.put("resource", AspireClient.serializeValue(resource)); + if (state != null) { + reqArgs.put("state", AspireClient.serializeValue(state)); + } + if (stateStyle != null) { + reqArgs.put("stateStyle", AspireClient.serializeValue(stateStyle)); + } + getClient().invokeCapability("Aspire.Hosting/publishResourceUpdate", reqArgs); + } + +} + /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext. */ class ResourceUrlsCallbackContext extends HandleWrapperBase { ResourceUrlsCallbackContext(Handle handle, AspireClient client) { @@ -4049,6 +4217,16 @@ public boolean waitForReadyAsync(double timeout, CancellationToken cancellationT return (boolean) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/waitForReadyAsync", reqArgs); } + /** Tests multi-param callback destructuring */ + public TestRedisResource withMultiParamHandleCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (TestRedisResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withMultiParamHandleCallback", reqArgs); + } + } /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext. */ @@ -4697,29 +4875,39 @@ class UpdateCommandStateContext extends HandleWrapperBase { /** Static initializer to register handle wrappers. */ class AspireRegistrations { static { + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", (h, c) -> new IDistributedApplicationBuilder(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplication", (h, c) -> new DistributedApplication(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference", (h, c) -> new EndpointReference(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", (h, c) -> new IResource(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", (h, c) -> new IResourceWithEnvironment(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", (h, c) -> new IResourceWithEndpoints(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs", (h, c) -> new IResourceWithArgs(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString", (h, c) -> new IResourceWithConnectionString(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport", (h, c) -> new IResourceWithWaitSupport(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithParent", (h, c) -> new IResourceWithParent(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource", (h, c) -> new ContainerResource(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource", (h, c) -> new ExecutableResource(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource", (h, c) -> new ProjectResource(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource", (h, c) -> new ParameterResource(h, c)); + AspireClient.registerHandleWrapper("System.ComponentModel/System.IServiceProvider", (h, c) -> new IServiceProvider(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceNotificationService", (h, c) -> new ResourceNotificationService(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceLoggerService", (h, c) -> new ResourceLoggerService(h, c)); + AspireClient.registerHandleWrapper("Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfiguration", (h, c) -> new IConfiguration(h, c)); + AspireClient.registerHandleWrapper("Microsoft.Extensions.Hosting.Abstractions/Microsoft.Extensions.Hosting.IHostEnvironment", (h, c) -> new IHostEnvironment(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription", (h, c) -> new DistributedApplicationEventSubscription(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext", (h, c) -> new DistributedApplicationExecutionContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContextOptions", (h, c) -> new DistributedApplicationExecutionContextOptions(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", (h, c) -> new IDistributedApplicationBuilder(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription", (h, c) -> new DistributedApplicationEventSubscription(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationResourceEventSubscription", (h, c) -> new DistributedApplicationResourceEventSubscription(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEvent", (h, c) -> new IDistributedApplicationEvent(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationResourceEvent", (h, c) -> new IDistributedApplicationResourceEvent(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEventing", (h, c) -> new IDistributedApplicationEventing(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext", (h, c) -> new CommandLineArgsCallbackContext(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference", (h, c) -> new EndpointReference(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression", (h, c) -> new EndpointReferenceExpression(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext", (h, c) -> new EnvironmentCallbackContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.UpdateCommandStateContext", (h, c) -> new UpdateCommandStateContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext", (h, c) -> new ExecuteCommandContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext", (h, c) -> new ResourceUrlsCallbackContext(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource", (h, c) -> new ContainerResource(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource", (h, c) -> new ExecutableResource(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource", (h, c) -> new ParameterResource(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString", (h, c) -> new IResourceWithConnectionString(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource", (h, c) -> new ProjectResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery", (h, c) -> new IResourceWithServiceDiscovery(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", (h, c) -> new IResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCallbackContext", (h, c) -> new TestCallbackContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext", (h, c) -> new TestResourceContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext", (h, c) -> new TestEnvironmentContext(h, c)); @@ -4728,10 +4916,6 @@ class AspireRegistrations { AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource", (h, c) -> new TestDatabaseResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestVaultResource", (h, c) -> new TestVaultResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource", (h, c) -> new ITestVaultResource(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", (h, c) -> new IResourceWithEnvironment(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs", (h, c) -> new IResourceWithArgs(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", (h, c) -> new IResourceWithEndpoints(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport", (h, c) -> new IResourceWithWaitSupport(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/List", (h, c) -> new AspireList(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict(h, c)); diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py index 354aa95884a..0cf7e213ee0 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py @@ -515,6 +515,14 @@ def wait_for_ready_async(self, timeout: float, cancellation_token: CancellationT args["cancellationToken"] = cancellation_token_id return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/waitForReadyAsync", args) + def with_multi_param_handle_callback(self, callback: Callable[[TestCallbackContext, TestEnvironmentContext], None]) -> TestRedisResource: + """Tests multi-param callback destructuring""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withMultiParamHandleCallback", args) + class TestResourceContext(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 2ebe21b148a..a783ab1a047 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -225,6 +225,12 @@ def to_dict(self) -> Dict[str, Any]: # Handle Wrappers # ============================================================================ +class CancellationToken(HandleWrapperBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + pass + class CommandLineArgsCallbackContext(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -1169,6 +1175,23 @@ def set_cancellation_token(self, value: CancellationToken) -> ExecuteCommandCont return self._client.invoke_capability("Aspire.Hosting.ApplicationModel/ExecuteCommandContext.setCancellationToken", args) +class IConfiguration(HandleWrapperBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + def get_config_value(self, key: str) -> str: + """Gets a configuration value by key""" + args: Dict[str, Any] = { "configuration": serialize_value(self._handle) } + args["key"] = serialize_value(key) + return self._client.invoke_capability("Aspire.Hosting/getConfigValue", args) + + def get_connection_string(self, name: str) -> str: + """Gets a connection string by name""" + args: Dict[str, Any] = { "configuration": serialize_value(self._handle) } + args["name"] = serialize_value(name) + return self._client.invoke_capability("Aspire.Hosting/getConnectionString", args) + + class IDistributedApplicationBuilder(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -1271,13 +1294,28 @@ def __init__(self, handle: Handle, client: AspireClient): pass +class IHostEnvironment(HandleWrapperBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + def get_environment_name(self) -> str: + """Gets the environment name""" + args: Dict[str, Any] = { "environment": serialize_value(self._handle) } + return self._client.invoke_capability("Aspire.Hosting/getEnvironmentName", args) + + def is_development(self) -> bool: + """Checks if running in Development environment""" + args: Dict[str, Any] = { "environment": serialize_value(self._handle) } + return self._client.invoke_capability("Aspire.Hosting/isDevelopment", args) + + class IResource(ResourceBuilderBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) pass -class IResourceWithArgs(HandleWrapperBase): +class IResourceWithArgs(ResourceBuilderBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -1289,13 +1327,19 @@ def __init__(self, handle: Handle, client: AspireClient): pass -class IResourceWithEndpoints(HandleWrapperBase): +class IResourceWithEndpoints(ResourceBuilderBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) pass -class IResourceWithEnvironment(HandleWrapperBase): +class IResourceWithEnvironment(ResourceBuilderBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + pass + +class IResourceWithParent(ResourceBuilderBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -1307,12 +1351,29 @@ def __init__(self, handle: Handle, client: AspireClient): pass -class IResourceWithWaitSupport(HandleWrapperBase): +class IResourceWithWaitSupport(ResourceBuilderBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) pass +class IServiceProvider(HandleWrapperBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + def get_service(self, type_id: str) -> Any: + """Gets a service by ATS type ID""" + args: Dict[str, Any] = { "serviceProvider": serialize_value(self._handle) } + args["typeId"] = serialize_value(type_id) + return self._client.invoke_capability("Aspire.Hosting/getService", args) + + def get_required_service(self, type_id: str) -> Any: + """Gets a required service by ATS type ID""" + args: Dict[str, Any] = { "serviceProvider": serialize_value(self._handle) } + args["typeId"] = serialize_value(type_id) + return self._client.invoke_capability("Aspire.Hosting/getRequiredService", args) + + class ITestVaultResource(ResourceBuilderBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -1848,6 +1909,76 @@ def __init__(self, handle: Handle, client: AspireClient): pass +class ResourceLoggerService(HandleWrapperBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + def complete_log(self, resource: IResource) -> None: + """Completes the log stream for a resource""" + args: Dict[str, Any] = { "loggerService": serialize_value(self._handle) } + args["resource"] = serialize_value(resource) + self._client.invoke_capability("Aspire.Hosting/completeLog", args) + return None + + def complete_log_by_name(self, resource_name: str) -> None: + """Completes the log stream by resource name""" + args: Dict[str, Any] = { "loggerService": serialize_value(self._handle) } + args["resourceName"] = serialize_value(resource_name) + self._client.invoke_capability("Aspire.Hosting/completeLogByName", args) + return None + + +class ResourceNotificationService(HandleWrapperBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + def wait_for_resource_state(self, resource_name: str, target_state: str | None = None) -> None: + """Waits for a resource to reach a specified state""" + args: Dict[str, Any] = { "notificationService": serialize_value(self._handle) } + args["resourceName"] = serialize_value(resource_name) + if target_state is not None: + args["targetState"] = serialize_value(target_state) + self._client.invoke_capability("Aspire.Hosting/waitForResourceState", args) + return None + + def wait_for_resource_states(self, resource_name: str, target_states: list[str]) -> str: + """Waits for a resource to reach one of the specified states""" + args: Dict[str, Any] = { "notificationService": serialize_value(self._handle) } + args["resourceName"] = serialize_value(resource_name) + args["targetStates"] = serialize_value(target_states) + return self._client.invoke_capability("Aspire.Hosting/waitForResourceStates", args) + + def wait_for_resource_healthy(self, resource_name: str) -> ResourceEventDto: + """Waits for a resource to become healthy""" + args: Dict[str, Any] = { "notificationService": serialize_value(self._handle) } + args["resourceName"] = serialize_value(resource_name) + return self._client.invoke_capability("Aspire.Hosting/waitForResourceHealthy", args) + + def wait_for_dependencies(self, resource: IResource) -> None: + """Waits for all dependencies of a resource to be ready""" + args: Dict[str, Any] = { "notificationService": serialize_value(self._handle) } + args["resource"] = serialize_value(resource) + self._client.invoke_capability("Aspire.Hosting/waitForDependencies", args) + return None + + def try_get_resource_state(self, resource_name: str) -> ResourceEventDto: + """Tries to get the current state of a resource""" + args: Dict[str, Any] = { "notificationService": serialize_value(self._handle) } + args["resourceName"] = serialize_value(resource_name) + return self._client.invoke_capability("Aspire.Hosting/tryGetResourceState", args) + + def publish_resource_update(self, resource: IResource, state: str | None = None, state_style: str | None = None) -> None: + """Publishes an update for a resource's state""" + args: Dict[str, Any] = { "notificationService": serialize_value(self._handle) } + args["resource"] = serialize_value(resource) + if state is not None: + args["state"] = serialize_value(state) + if state_style is not None: + args["stateStyle"] = serialize_value(state_style) + self._client.invoke_capability("Aspire.Hosting/publishResourceUpdate", args) + return None + + class ResourceUrlsCallbackContext(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -2876,6 +3007,14 @@ def wait_for_ready_async(self, timeout: float, cancellation_token: CancellationT args["cancellationToken"] = cancellation_token_id return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/waitForReadyAsync", args) + def with_multi_param_handle_callback(self, callback: Callable[[TestCallbackContext, TestEnvironmentContext], None]) -> TestRedisResource: + """Tests multi-param callback destructuring""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withMultiParamHandleCallback", args) + class TestResourceContext(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): @@ -3348,30 +3487,41 @@ def __init__(self, handle: Handle, client: AspireClient): # Handle wrapper registrations # ============================================================================ +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", lambda handle, client: IDistributedApplicationBuilder(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplication", lambda handle, client: DistributedApplication(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference", lambda handle, client: EndpointReference(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression", lambda handle, client: ReferenceExpression(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", lambda handle, client: IResource(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", lambda handle, client: IResourceWithEnvironment(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", lambda handle, client: IResourceWithEndpoints(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs", lambda handle, client: IResourceWithArgs(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString", lambda handle, client: IResourceWithConnectionString(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport", lambda handle, client: IResourceWithWaitSupport(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithParent", lambda handle, client: IResourceWithParent(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource", lambda handle, client: ContainerResource(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource", lambda handle, client: ExecutableResource(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource", lambda handle, client: ProjectResource(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource", lambda handle, client: ParameterResource(handle, client)) +register_handle_wrapper("System.ComponentModel/System.IServiceProvider", lambda handle, client: IServiceProvider(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceNotificationService", lambda handle, client: ResourceNotificationService(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceLoggerService", lambda handle, client: ResourceLoggerService(handle, client)) +register_handle_wrapper("Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfiguration", lambda handle, client: IConfiguration(handle, client)) +register_handle_wrapper("Microsoft.Extensions.Hosting.Abstractions/Microsoft.Extensions.Hosting.IHostEnvironment", lambda handle, client: IHostEnvironment(handle, client)) +register_handle_wrapper("System.Private.CoreLib/System.Threading.CancellationToken", lambda handle, client: CancellationToken(handle, client)) +register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription", lambda handle, client: DistributedApplicationEventSubscription(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext", lambda handle, client: DistributedApplicationExecutionContext(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContextOptions", lambda handle, client: DistributedApplicationExecutionContextOptions(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", lambda handle, client: IDistributedApplicationBuilder(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription", lambda handle, client: DistributedApplicationEventSubscription(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationResourceEventSubscription", lambda handle, client: DistributedApplicationResourceEventSubscription(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEvent", lambda handle, client: IDistributedApplicationEvent(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationResourceEvent", lambda handle, client: IDistributedApplicationResourceEvent(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEventing", lambda handle, client: IDistributedApplicationEventing(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext", lambda handle, client: CommandLineArgsCallbackContext(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference", lambda handle, client: EndpointReference(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression", lambda handle, client: EndpointReferenceExpression(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext", lambda handle, client: EnvironmentCallbackContext(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression", lambda handle, client: ReferenceExpression(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.UpdateCommandStateContext", lambda handle, client: UpdateCommandStateContext(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext", lambda handle, client: ExecuteCommandContext(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext", lambda handle, client: ResourceUrlsCallbackContext(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource", lambda handle, client: ContainerResource(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource", lambda handle, client: ExecutableResource(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource", lambda handle, client: ParameterResource(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString", lambda handle, client: IResourceWithConnectionString(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource", lambda handle, client: ProjectResource(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery", lambda handle, client: IResourceWithServiceDiscovery(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", lambda handle, client: IResource(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCallbackContext", lambda handle, client: TestCallbackContext(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext", lambda handle, client: TestResourceContext(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext", lambda handle, client: TestEnvironmentContext(handle, client)) @@ -3380,10 +3530,6 @@ def __init__(self, handle: Handle, client: AspireClient): register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource", lambda handle, client: TestDatabaseResource(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestVaultResource", lambda handle, client: TestVaultResource(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource", lambda handle, client: ITestVaultResource(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", lambda handle, client: IResourceWithEnvironment(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs", lambda handle, client: IResourceWithArgs(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", lambda handle, client: IResourceWithEndpoints(handle, client)) -register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport", lambda handle, client: IResourceWithWaitSupport(handle, client)) register_handle_wrapper("Aspire.Hosting/Dict", lambda handle, client: AspireDict(handle, client)) register_handle_wrapper("Aspire.Hosting/List", lambda handle, client: AspireList(handle, client)) register_handle_wrapper("Aspire.Hosting/Dict", lambda handle, client: AspireDict(handle, client)) diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs index b17a8468856..f37f76ac6a1 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs @@ -960,6 +960,17 @@ impl TestRedisResource { let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/waitForReadyAsync", args)?; Ok(serde_json::from_value(result)?) } + + /// Tests multi-param callback destructuring + pub fn with_multi_param_handle_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withMultiParamHandleCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(TestRedisResource::new(handle, self.client.clone())) + } } /// Wrapper for Aspire.Hosting.CodeGeneration.Rust.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index b2c74d37edd..883d775f201 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -2197,6 +2197,50 @@ impl ExecuteCommandContext { } } +/// Wrapper for Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfiguration +pub struct IConfiguration { + handle: Handle, + client: Arc, +} + +impl HasHandle for IConfiguration { + fn handle(&self) -> &Handle { + &self.handle + } +} + +impl IConfiguration { + pub fn new(handle: Handle, client: Arc) -> Self { + Self { handle, client } + } + + pub fn handle(&self) -> &Handle { + &self.handle + } + + pub fn client(&self) -> &Arc { + &self.client + } + + /// Gets a configuration value by key + pub fn get_config_value(&self, key: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("configuration".to_string(), self.handle.to_json()); + args.insert("key".to_string(), serde_json::to_value(&key).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/getConfigValue", args)?; + Ok(serde_json::from_value(result)?) + } + + /// Gets a connection string by name + pub fn get_connection_string(&self, name: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("configuration".to_string(), self.handle.to_json()); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/getConnectionString", args)?; + Ok(serde_json::from_value(result)?) + } +} + /// Wrapper for Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder pub struct IDistributedApplicationBuilder { handle: Handle, @@ -2430,6 +2474,48 @@ impl IDistributedApplicationResourceEvent { } } +/// Wrapper for Microsoft.Extensions.Hosting.Abstractions/Microsoft.Extensions.Hosting.IHostEnvironment +pub struct IHostEnvironment { + handle: Handle, + client: Arc, +} + +impl HasHandle for IHostEnvironment { + fn handle(&self) -> &Handle { + &self.handle + } +} + +impl IHostEnvironment { + pub fn new(handle: Handle, client: Arc) -> Self { + Self { handle, client } + } + + pub fn handle(&self) -> &Handle { + &self.handle + } + + pub fn client(&self) -> &Arc { + &self.client + } + + /// Gets the environment name + pub fn get_environment_name(&self) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("environment".to_string(), self.handle.to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/getEnvironmentName", args)?; + Ok(serde_json::from_value(result)?) + } + + /// Checks if running in Development environment + pub fn is_development(&self) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("environment".to_string(), self.handle.to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/isDevelopment", args)?; + Ok(serde_json::from_value(result)?) + } +} + /// Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource pub struct IResource { handle: Handle, @@ -2560,6 +2646,32 @@ impl IResourceWithEnvironment { } } +/// Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithParent +pub struct IResourceWithParent { + handle: Handle, + client: Arc, +} + +impl HasHandle for IResourceWithParent { + fn handle(&self) -> &Handle { + &self.handle + } +} + +impl IResourceWithParent { + pub fn new(handle: Handle, client: Arc) -> Self { + Self { handle, client } + } + + pub fn handle(&self) -> &Handle { + &self.handle + } + + pub fn client(&self) -> &Arc { + &self.client + } +} + /// Wrapper for Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery pub struct IResourceWithServiceDiscovery { handle: Handle, @@ -2612,6 +2724,50 @@ impl IResourceWithWaitSupport { } } +/// Wrapper for System.ComponentModel/System.IServiceProvider +pub struct IServiceProvider { + handle: Handle, + client: Arc, +} + +impl HasHandle for IServiceProvider { + fn handle(&self) -> &Handle { + &self.handle + } +} + +impl IServiceProvider { + pub fn new(handle: Handle, client: Arc) -> Self { + Self { handle, client } + } + + pub fn handle(&self) -> &Handle { + &self.handle + } + + pub fn client(&self) -> &Arc { + &self.client + } + + /// Gets a service by ATS type ID + pub fn get_service(&self, type_id: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("serviceProvider".to_string(), self.handle.to_json()); + args.insert("typeId".to_string(), serde_json::to_value(&type_id).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/getService", args)?; + Ok(serde_json::from_value(result)?) + } + + /// Gets a required service by ATS type ID + pub fn get_required_service(&self, type_id: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("serviceProvider".to_string(), self.handle.to_json()); + args.insert("typeId".to_string(), serde_json::to_value(&type_id).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/getRequiredService", args)?; + Ok(serde_json::from_value(result)?) + } +} + /// Wrapper for Aspire.Hosting.CodeGeneration.Rust.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource pub struct ITestVaultResource { handle: Handle, @@ -3500,6 +3656,140 @@ impl ProjectResource { } } +/// Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceLoggerService +pub struct ResourceLoggerService { + handle: Handle, + client: Arc, +} + +impl HasHandle for ResourceLoggerService { + fn handle(&self) -> &Handle { + &self.handle + } +} + +impl ResourceLoggerService { + pub fn new(handle: Handle, client: Arc) -> Self { + Self { handle, client } + } + + pub fn handle(&self) -> &Handle { + &self.handle + } + + pub fn client(&self) -> &Arc { + &self.client + } + + /// Completes the log stream for a resource + pub fn complete_log(&self, resource: &IResource) -> Result<(), Box> { + let mut args: HashMap = HashMap::new(); + args.insert("loggerService".to_string(), self.handle.to_json()); + args.insert("resource".to_string(), resource.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/completeLog", args)?; + Ok(()) + } + + /// Completes the log stream by resource name + pub fn complete_log_by_name(&self, resource_name: &str) -> Result<(), Box> { + let mut args: HashMap = HashMap::new(); + args.insert("loggerService".to_string(), self.handle.to_json()); + args.insert("resourceName".to_string(), serde_json::to_value(&resource_name).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/completeLogByName", args)?; + Ok(()) + } +} + +/// Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceNotificationService +pub struct ResourceNotificationService { + handle: Handle, + client: Arc, +} + +impl HasHandle for ResourceNotificationService { + fn handle(&self) -> &Handle { + &self.handle + } +} + +impl ResourceNotificationService { + pub fn new(handle: Handle, client: Arc) -> Self { + Self { handle, client } + } + + pub fn handle(&self) -> &Handle { + &self.handle + } + + pub fn client(&self) -> &Arc { + &self.client + } + + /// Waits for a resource to reach a specified state + pub fn wait_for_resource_state(&self, resource_name: &str, target_state: Option<&str>) -> Result<(), Box> { + let mut args: HashMap = HashMap::new(); + args.insert("notificationService".to_string(), self.handle.to_json()); + args.insert("resourceName".to_string(), serde_json::to_value(&resource_name).unwrap_or(Value::Null)); + if let Some(ref v) = target_state { + args.insert("targetState".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/waitForResourceState", args)?; + Ok(()) + } + + /// Waits for a resource to reach one of the specified states + pub fn wait_for_resource_states(&self, resource_name: &str, target_states: Vec) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("notificationService".to_string(), self.handle.to_json()); + args.insert("resourceName".to_string(), serde_json::to_value(&resource_name).unwrap_or(Value::Null)); + args.insert("targetStates".to_string(), serde_json::to_value(&target_states).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/waitForResourceStates", args)?; + Ok(serde_json::from_value(result)?) + } + + /// Waits for a resource to become healthy + pub fn wait_for_resource_healthy(&self, resource_name: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("notificationService".to_string(), self.handle.to_json()); + args.insert("resourceName".to_string(), serde_json::to_value(&resource_name).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/waitForResourceHealthy", args)?; + Ok(serde_json::from_value(result)?) + } + + /// Waits for all dependencies of a resource to be ready + pub fn wait_for_dependencies(&self, resource: &IResource) -> Result<(), Box> { + let mut args: HashMap = HashMap::new(); + args.insert("notificationService".to_string(), self.handle.to_json()); + args.insert("resource".to_string(), resource.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/waitForDependencies", args)?; + Ok(()) + } + + /// Tries to get the current state of a resource + pub fn try_get_resource_state(&self, resource_name: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("notificationService".to_string(), self.handle.to_json()); + args.insert("resourceName".to_string(), serde_json::to_value(&resource_name).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/tryGetResourceState", args)?; + Ok(serde_json::from_value(result)?) + } + + /// Publishes an update for a resource's state + pub fn publish_resource_update(&self, resource: &IResource, state: Option<&str>, state_style: Option<&str>) -> Result<(), Box> { + let mut args: HashMap = HashMap::new(); + args.insert("notificationService".to_string(), self.handle.to_json()); + args.insert("resource".to_string(), resource.handle().to_json()); + if let Some(ref v) = state { + args.insert("state".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = state_style { + args.insert("stateStyle".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/publishResourceUpdate", args)?; + Ok(()) + } +} + /// Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext pub struct ResourceUrlsCallbackContext { handle: Handle, @@ -5195,6 +5485,17 @@ impl TestRedisResource { let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/waitForReadyAsync", args)?; Ok(serde_json::from_value(result)?) } + + /// Tests multi-param callback destructuring + pub fn with_multi_param_handle_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withMultiParamHandleCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(TestRedisResource::new(handle, self.client.clone())) + } } /// Wrapper for Aspire.Hosting.CodeGeneration.Rust.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs index f746777d558..e0dd7001a6f 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs @@ -1270,6 +1270,21 @@ public void Generate_ConcreteAndInterfaceWithSameClassName_NoDuplicateClasses() Assert.Equal(1, promiseCount); } + // ===== Multi-Parameter Callback Destructuring Tests ===== + + [Fact] + public void Generate_MultiParamCallback_UsesPerPropertyTyping() + { + // Regression test: multi-parameter callbacks must type each destructured property + // individually as { p0: unknown, p1: unknown }, not { p0, p1: unknown } which + // only types the last property in TypeScript. + var code = GenerateTwoPassCode(); + + // withMultiParamHandleCallback has a 2-param callback (TestCallbackContext, TestEnvironmentContext) + // The generated destructuring should type each property: { p0: unknown, p1: unknown } + Assert.Contains("{ p0: unknown, p1: unknown }", code); + } + private static int CountOccurrences(string text, string pattern) { var count = 0; diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts index 18465056d18..a6be97c058f 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts @@ -1236,6 +1236,29 @@ export class TestRedisResource extends ResourceBuilderBase Promise): Promise { + const callbackId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1Handle = wrapIfHandle(args.p0) as TestCallbackContextHandle; + const arg1 = new TestCallbackContext(arg1Handle, this._client); + const arg2Handle = wrapIfHandle(args.p1) as TestEnvironmentContextHandle; + const arg2 = new TestEnvironmentContext(arg2Handle, this._client); + await callback(arg1, arg2); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withMultiParamHandleCallback', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Tests multi-param callback destructuring */ + withMultiParamHandleCallback(callback: (arg1: TestCallbackContext, arg2: TestEnvironmentContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withMultiParamHandleCallbackInternal(callback)); + } + } /** @@ -1378,6 +1401,11 @@ export class TestRedisResourcePromise implements PromiseLike return this._promise.then(obj => obj.waitForReadyAsync(timeout, options)); } + /** Tests multi-param callback destructuring */ + withMultiParamHandleCallback(callback: (arg1: TestCallbackContext, arg2: TestEnvironmentContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._promise.then(obj => obj.withMultiParamHandleCallback(callback))); + } + } // ============================================================================ diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index fa82767376d..6e081485406 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -7467,6 +7467,29 @@ export class TestRedisResource extends ResourceBuilderBase Promise): Promise { + const callbackId = registerCallback(async (argsData: unknown) => { + const args = argsData as { p0: unknown, p1: unknown }; + const arg1Handle = wrapIfHandle(args.p0) as TestCallbackContextHandle; + const arg1 = new TestCallbackContext(arg1Handle, this._client); + const arg2Handle = wrapIfHandle(args.p1) as TestEnvironmentContextHandle; + const arg2 = new TestEnvironmentContext(arg2Handle, this._client); + await callback(arg1, arg2); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withMultiParamHandleCallback', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Tests multi-param callback destructuring */ + withMultiParamHandleCallback(callback: (arg1: TestCallbackContext, arg2: TestEnvironmentContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withMultiParamHandleCallbackInternal(callback)); + } + } /** @@ -7804,6 +7827,11 @@ export class TestRedisResourcePromise implements PromiseLike return this._promise.then(obj => obj.waitForReadyAsync(timeout, options)); } + /** Tests multi-param callback destructuring */ + withMultiParamHandleCallback(callback: (arg1: TestCallbackContext, arg2: TestEnvironmentContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._promise.then(obj => obj.withMultiParamHandleCallback(callback))); + } + } // ============================================================================ diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestTypes/TestExtensions.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestTypes/TestExtensions.cs index 8c1f1e68960..e6cf4e29d11 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestTypes/TestExtensions.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestTypes/TestExtensions.cs @@ -635,6 +635,19 @@ public static Task WaitForReadyAsync( return Task.FromResult(true); } + // ===== Multi-Parameter Callback Tests ===== + + /// + /// Tests multi-parameter callback with handle types for destructuring codegen. + /// + [AspireExport("withMultiParamHandleCallback", Description = "Tests multi-param callback destructuring")] + public static IResourceBuilder WithMultiParamHandleCallback( + this IResourceBuilder builder, + Func callback) + { + return builder; + } + // ===== Duplicate Class Name Tests ===== /// diff --git a/tests/Aspire.Hosting.Tests/Ats/AtsCapabilityScannerTests.cs b/tests/Aspire.Hosting.Tests/Ats/AtsCapabilityScannerTests.cs index fee9bad754a..26b94b826b2 100644 --- a/tests/Aspire.Hosting.Tests/Ats/AtsCapabilityScannerTests.cs +++ b/tests/Aspire.Hosting.Tests/Ats/AtsCapabilityScannerTests.cs @@ -194,6 +194,60 @@ public void DerivePackage_NestedCapabilityId_ReturnsPackage() #endregion + #region Assembly-Level AspireExport Tests + + [Fact] + public void ScanAssembly_AssemblyLevelExport_AppearsInHandleTypes() + { + // Regression test: assembly-level [AspireExport(typeof(T))] attributes must be + // discovered and included in HandleTypes so they participate in Unknown→Handle resolution. + // The Aspire.Hosting assembly exports CancellationToken at assembly level. + var hostingAssembly = typeof(DistributedApplication).Assembly; + var result = AtsCapabilityScanner.ScanAssembly(hostingAssembly); + + // ContainerApp types are exported via assembly-level attributes in AppContainers, + // but CancellationToken is exported in Aspire.Hosting's AtsTypeMappings.cs + var cancellationTokenType = result.HandleTypes + .FirstOrDefault(t => t.AtsTypeId.Contains("CancellationToken")); + + Assert.NotNull(cancellationTokenType); + } + + #endregion + + #region Callback Parameter Type Resolution Tests + + [Fact] + public void ScanAssembly_MultiParamCallbackTypes_AreResolved() + { + // Regression test: callback parameter types must be resolved (not left as Unknown) + // when the types are exported. Previously only param.Type was resolved but not + // param.CallbackParameters[i].Type. + var testAssembly = typeof(AtsCapabilityScannerTests).Assembly; + var hostingAssembly = typeof(DistributedApplication).Assembly; + + var result = AtsCapabilityScanner.ScanAssemblies([hostingAssembly, testAssembly]); + + // Find the testMultiParamHandleCallback capability + var capability = result.Capabilities + .FirstOrDefault(c => c.CapabilityId.EndsWith("/testMultiParamHandleCallback", StringComparison.Ordinal)); + + Assert.NotNull(capability); + + var callbackParam = Assert.Single(capability.Parameters, p => p.IsCallback); + Assert.NotNull(callbackParam.CallbackParameters); + Assert.Equal(2, callbackParam.CallbackParameters.Count); + + // Both callback parameter types should be resolved to Handle (not Unknown) + foreach (var cbParam in callbackParam.CallbackParameters) + { + Assert.NotNull(cbParam.Type); + Assert.NotEqual(AtsTypeCategory.Unknown, cbParam.Type.Category); + } + } + + #endregion + #region Test Types private sealed class TestResource : Resource @@ -218,6 +272,15 @@ public static IEnumerable TestEnumerableReturn(IDistributedApplicationBu _ = builder; return []; } + + [AspireExport("testMultiParamHandleCallback")] + public static IResourceBuilder TestMultiParamHandleCallback( + IResourceBuilder builder, + Func callback) + { + _ = callback; + return builder; + } } #endregion From 5a3c52a33078ea0c135db863f659fb4e0a9c3873 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 3 Mar 2026 20:24:23 +1100 Subject: [PATCH 17/18] Replace Playwright MCP server with Playwright CLI installation (#14569) * Replace Playwright MCP server with Playwright CLI installation Replace the Playwright MCP server configuration in `aspire agent init` with a secure Playwright CLI installation workflow. Instead of writing MCP server configuration to each agent environment's config file, the new approach: - Resolves the @playwright/cli package version from npm registry - Downloads the package tarball via `npm pack` - Verifies supply chain integrity (SHA-512 SRI hash comparison) - Runs `npm audit signatures` for provenance verification - Installs globally from the verified tarball - Runs `playwright-cli install --skills` to generate skill files New abstractions: - INpmRunner/NpmRunner: npm CLI command runner (resolve, pack, audit, install) - IPlaywrightCliRunner/PlaywrightCliRunner: playwright-cli command runner - PlaywrightCliInstaller: orchestrates the secure install flow This removes ~400 lines of per-scanner MCP config writing code (different JSON formats for VS Code, Claude Code, Copilot CLI, and OpenCode) and replaces it with a single global CLI install. Fixes #14430 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Pin Playwright CLI version range to 0.1.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add npm provenance verification and break-glass config - Add INpmProvenanceChecker/NpmProvenanceChecker for SLSA attestation verification - Return rich ProvenanceVerificationResult with gate-specific outcome enum - Fix AuditSignaturesAsync with temp-project approach for global tools - Add disablePlaywrightCliPackageValidation break-glass config option - Add security design document (docs/specs/safe-npm-tool-install.md) - Verify SRI integrity, Sigstore attestations, and source repository provenance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix markdownlint: add language to fenced code block Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve version resolution and provenance verification - Change version range from exact 0.1.1 to >=0.1.1 for future versions - Add playwrightCliVersion config override for pinning specific versions - Verify workflow path (.github/workflows/publish.yml) in provenance - Verify SLSA build type (GitHub Actions) to confirm OIDC token issuer - Add BuildType to NpmProvenanceData, WorkflowMismatch and BuildTypeMismatch outcomes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add tests for version pinning and default version range Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add E2E test for Playwright CLI installation via agent init Verifies the full lifecycle: project creation, aspire agent init with Claude Code environment, Playwright CLI installation with npm provenance verification, and skill file generation. Marked as OuterloopTest since it requires npm and network access. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Show status spinner during Playwright CLI installation Wrap the installation work in IInteractionService.ShowStatusAsync to display a spinner with 'Installing Playwright CLI...' status text while the npm operations are in progress. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Mirror playwright-cli skill files to all detected agent environments After playwright-cli install --skills creates files in .claude/skills/, the installer now mirrors the playwright-cli skill directory to all other detected agent environment skill directories (.github/skills/, .opencode/skill/, etc.) so every configured environment has identical skill files. Stale files in target directories are also removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update security design doc to match implementation - Step 4 now documents all three provenance gates: source repository, workflow path, and build type (with OIDC issuer implication) - Added table of verified fields with expected values - Updated implementation constants to include new fields - Added configuration section documenting break-glass keys - Updated verification diagram with workflow/build type checks - Step 7 now documents skill file mirroring across environments - Future improvements reflects experimental Sigstore branch status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Verify workflow ref matches package version in provenance Add Gate 6 to provenance verification: check that the workflow ref (git tag) in the SLSA attestation matches refs/tags/v{version}. This ensures the build was triggered from the expected release tag, not an arbitrary branch or commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use callback-based workflow ref validation in provenance checker Make the workflow ref validation configurable per-package by accepting a Func callback instead of hardcoding the refs/tags/v{version} format. The ref is parsed into a WorkflowRefInfo record (Raw, Kind, Name) and the caller decides what's valid. PlaywrightCliInstaller validates Kind=tags and Name=v{version}. Other packages can use different tag conventions without modifying the provenance checker. Addresses review feedback from DamianEdwards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Replace npm provenance checking with Sigstore verification Use the Sigstore and Tuf NuGet packages (0.2.0) to cryptographically verify npm attestation bundles in-process, replacing the previous approach of shelling out to 'npm audit signatures'. - Add SigstoreNpmProvenanceChecker implementing INpmProvenanceChecker using SigstoreVerifier with CertificateIdentity.ForGitHubActions - Remove the npm audit signatures step from PlaywrightCliInstaller - Keep existing NpmProvenanceChecker but no longer register in DI - Add optional sriIntegrity parameter to INpmProvenanceChecker for digest-based bundle verification - Update safe-npm-tool-install.md spec to reflect new verification flow - Temporarily add nuget.org to NuGet.config for Sigstore/Tuf packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refactor SigstoreNpmProvenanceChecker for clarity Break the monolithic VerifyProvenanceAsync method into focused methods: - FetchAttestationJsonAsync: fetches attestation JSON from npm registry - ParseAttestation: parses JSON in a single pass, extracting both the Sigstore bundle and provenance data (eliminates duplicate JSON parsing) - ParseProvenanceFromStatement: extracts provenance fields from in-toto statement - VerifySigstoreBundleAsync: cryptographic Sigstore verification - VerifyProvenanceFields: field-level provenance checks (source repo, workflow, build type, workflow ref) Removes dependency on NpmProvenanceChecker.ParseProvenance() which was re-parsing the same JSON and iterating attestations a second time. Adds NpmAttestationParseResult type to carry both bundle and provenance data from a single parse pass. Adds comprehensive unit tests for ParseAttestation, ParseProvenanceFromStatement, and VerifyProvenanceFields covering success and failure cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add libsodium to nuget-org package source mapping libsodium is a transitive dependency of NSec.Cryptography (used by Sigstore) and needs to be mapped to the nuget-org source for CI restore to succeed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 + NuGet.config | 9 + docs/specs/safe-npm-tool-install.md | 183 ++++++ .../Agents/AgentEnvironmentScanContext.cs | 38 +- .../ClaudeCodeAgentEnvironmentScanner.cs | 101 ++-- .../Agents/CommonAgentApplicators.cs | 34 +- .../CopilotCliAgentEnvironmentScanner.cs | 113 ++-- .../OpenCodeAgentEnvironmentScanner.cs | 103 ++-- .../Agents/Playwright/IPlaywrightCliRunner.cs | 26 + .../Playwright/PlaywrightCliInstaller.cs | 352 +++++++++++ .../Agents/Playwright/PlaywrightCliRunner.cs | 128 ++++ .../VsCode/VsCodeAgentEnvironmentScanner.cs | 100 ++- src/Aspire.Cli/Aspire.Cli.csproj | 5 + src/Aspire.Cli/Npm/INpmProvenanceChecker.cs | 194 ++++++ src/Aspire.Cli/Npm/INpmRunner.cs | 67 +++ src/Aspire.Cli/Npm/NpmProvenanceChecker.cs | 233 +++++++ src/Aspire.Cli/Npm/NpmRunner.cs | 277 +++++++++ .../Npm/SigstoreNpmProvenanceChecker.cs | 393 ++++++++++++ src/Aspire.Cli/Program.cs | 6 + .../Resources/McpCommandStrings.Designer.cs | 2 +- .../Resources/McpCommandStrings.resx | 2 +- .../Resources/xlf/McpCommandStrings.cs.xlf | 4 +- .../Resources/xlf/McpCommandStrings.de.xlf | 4 +- .../Resources/xlf/McpCommandStrings.es.xlf | 4 +- .../Resources/xlf/McpCommandStrings.fr.xlf | 4 +- .../Resources/xlf/McpCommandStrings.it.xlf | 4 +- .../Resources/xlf/McpCommandStrings.ja.xlf | 4 +- .../Resources/xlf/McpCommandStrings.ko.xlf | 4 +- .../Resources/xlf/McpCommandStrings.pl.xlf | 4 +- .../Resources/xlf/McpCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/McpCommandStrings.ru.xlf | 4 +- .../Resources/xlf/McpCommandStrings.tr.xlf | 4 +- .../xlf/McpCommandStrings.zh-Hans.xlf | 4 +- .../xlf/McpCommandStrings.zh-Hant.xlf | 4 +- .../PlaywrightCliInstallTests.cs | 160 +++++ .../ClaudeCodeAgentEnvironmentScannerTests.cs | 20 +- .../CopilotCliAgentEnvironmentScannerTests.cs | 47 +- .../Agents/NpmProvenanceCheckerTests.cs | 308 ++++++++++ .../OpenCodeAgentEnvironmentScannerTests.cs | 20 +- .../Agents/PlaywrightCliInstallerTests.cs | 567 ++++++++++++++++++ .../SigstoreNpmProvenanceCheckerTests.cs | 325 ++++++++++ .../VsCodeAgentEnvironmentScannerTests.cs | 76 ++- .../Aspire.Cli.Tests/Aspire.Cli.Tests.csproj | 1 + .../TestServices/FakePlaywrightServices.cs | 51 ++ 44 files changed, 3603 insertions(+), 392 deletions(-) create mode 100644 docs/specs/safe-npm-tool-install.md create mode 100644 src/Aspire.Cli/Agents/Playwright/IPlaywrightCliRunner.cs create mode 100644 src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs create mode 100644 src/Aspire.Cli/Agents/Playwright/PlaywrightCliRunner.cs create mode 100644 src/Aspire.Cli/Npm/INpmProvenanceChecker.cs create mode 100644 src/Aspire.Cli/Npm/INpmRunner.cs create mode 100644 src/Aspire.Cli/Npm/NpmProvenanceChecker.cs create mode 100644 src/Aspire.Cli/Npm/NpmRunner.cs create mode 100644 src/Aspire.Cli/Npm/SigstoreNpmProvenanceChecker.cs create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs create mode 100644 tests/Aspire.Cli.Tests/Agents/NpmProvenanceCheckerTests.cs create mode 100644 tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs create mode 100644 tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 03cc81a3c29..d66793059c3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -132,6 +132,8 @@ + + diff --git a/NuGet.config b/NuGet.config index 2822bc5d52a..6ffc9600bba 100644 --- a/NuGet.config +++ b/NuGet.config @@ -20,6 +20,8 @@ + + @@ -43,6 +45,13 @@ + + + + + + + diff --git a/docs/specs/safe-npm-tool-install.md b/docs/specs/safe-npm-tool-install.md new file mode 100644 index 00000000000..ab4367bb022 --- /dev/null +++ b/docs/specs/safe-npm-tool-install.md @@ -0,0 +1,183 @@ +# Safe npm Global Tool Installation + +## Overview + +The Aspire CLI installs the `@playwright/cli` npm package as a global tool during `aspire agent init`. Because this tool runs with the user's full privileges, we must verify its authenticity and provenance before installation. This document describes the verification process, the threat model, and the reasoning behind each step. + +## Threat Model + +### What we're protecting against + +1. **Registry compromise** — An attacker gains write access to the npm registry and publishes a malicious version of `@playwright/cli` +2. **Publish token theft** — An attacker steals a maintainer's npm publish token and publishes a tampered package +3. **Man-in-the-middle** — An attacker intercepts the network request and substitutes a different tarball +4. **Dependency confusion** — A malicious package with a similar name is installed instead of the intended one + +### What we're NOT protecting against + +- Compromise of the legitimate source repository (`microsoft/playwright-cli`) itself +- Compromise of the GitHub Actions build infrastructure (Sigstore OIDC provider) +- Compromise of the Sigstore transparency log infrastructure +- Malicious code introduced through legitimate dependencies of `@playwright/cli` + +### Trust anchors + +Our verification chain relies on these trust anchors: + +| Trust anchor | What it provides | How it's protected | +|---|---|---| +| **npm registry** | Package metadata, tarball hosting | HTTPS/TLS, npm's infrastructure security | +| **Sigstore (Fulcio + Rekor)** | Cryptographic attestation signatures | Public CA with OIDC federation, append-only transparency log, verified in-process via Sigstore .NET library with TUF trust root | +| **GitHub Actions OIDC** | Builder identity claims in Sigstore certificates | GitHub's infrastructure security | +| **Hardcoded expected values** | Package name, version range, expected source repository | Code review, our own release process | + +## Verification Process + +### Step 1: Resolve package version and metadata + +**Action:** Run `npm view @playwright/cli@{versionRange} version` and `npm view @playwright/cli@{version} dist.integrity` to get the resolved version and the registry's SRI integrity hash. The default version range is `>=0.1.1`, which resolves to the latest published version at or above 0.1.1. This can be overridden to a specific version via the `playwrightCliVersion` configuration key. + +**What this establishes:** We know the exact version we intend to install and the hash the registry claims for its tarball. + +**Trust basis:** npm registry over HTTPS/TLS. + +**Limitations:** If the registry is compromised, both the version and hash could be attacker-controlled. This step alone is insufficient — it only establishes what the registry *claims*. + +### Step 2: Check if already installed at a suitable version + +**Action:** Run `playwright-cli --version` and compare against the resolved version. + +**What this establishes:** Whether installation can be skipped entirely (already up-to-date or newer). + +**Trust basis:** The previously-installed binary. If the user's system is compromised, this could be spoofed, but that's outside our threat model. + +### Step 3: Verify Sigstore attestation and provenance metadata + +**Action:** +1. Fetch the attestation bundle from `https://registry.npmjs.org/-/npm/v1/attestations/@playwright/cli@{version}` +2. Find the attestation with `predicateType: "https://slsa.dev/provenance/v1"` (SLSA Build L3 provenance) +3. Extract the Sigstore bundle from the `bundle` field of the attestation +4. Cryptographically verify the Sigstore bundle using the `SigstoreVerifier` from the [Sigstore .NET library](https://github.com/mitchdenny/sigstore-dotnet), with a `VerificationPolicy` configured for `CertificateIdentity.ForGitHubActions("microsoft", "playwright-cli")` +5. Base64-decode the DSSE envelope payload to extract the in-toto statement +6. Verify the following fields from the provenance predicate: + +| Field | Location in payload | Expected value | What it proves | +|---|---|---|---| +| **Source repository** | `predicate.buildDefinition.externalParameters.workflow.repository` | `https://github.com/microsoft/playwright-cli` | The package was built from the legitimate source code | +| **Workflow path** | `predicate.buildDefinition.externalParameters.workflow.path` | `.github/workflows/publish.yml` | The build used the expected CI pipeline, not an ad-hoc or attacker-injected workflow | +| **Build type** | `predicate.buildDefinition.buildType` | `https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1` | The build ran on GitHub Actions, which implicitly confirms the OIDC token issuer is `https://token.actions.githubusercontent.com` | +| **Workflow ref** | `predicate.buildDefinition.externalParameters.workflow.ref` | Validated via caller-provided callback (for `@playwright/cli`: kind=`tags`, name=`v{version}`) | The build was triggered from a version tag matching the package version, not an arbitrary branch or commit. The tag format is package-specific — different packages may use different conventions (e.g., `v0.1.1`, `0.1.1`, `@scope/pkg@0.1.1`). The ref is parsed into structured components (`WorkflowRefInfo`) and the caller provides a validation callback. | + +**What this establishes:** That the Sigstore bundle is cryptographically authentic — the signing certificate was issued by Sigstore's Fulcio CA, the signature is recorded in the Rekor transparency log, and the OIDC identity in the certificate matches the `microsoft/playwright-cli` GitHub Actions workflow. Additionally, the provenance metadata confirms the package was built from the expected repository, workflow, CI system, and version tag. + +**Trust basis:** Sigstore's public key infrastructure via the `Sigstore` and `Tuf` .NET libraries. The TUF trust root is automatically downloaded and verified. Even if the npm registry is compromised, an attacker cannot forge valid Sigstore signatures — they would need to compromise Fulcio (the Sigstore CA) or obtain a valid OIDC token from GitHub Actions for the legitimate repository's workflow. Since the Sigstore verification and provenance field checking happen on the same attestation bundle in a single operation, there is no TOCTOU gap between signature verification and content inspection. + +**Why we verify all provenance fields:** Checking only the Sigstore certificate identity (GitHub Actions + repository) is necessary but not sufficient. An attacker with write access to the repo could introduce a malicious workflow (e.g., `.github/workflows/evil.yml`). By also verifying the workflow path, build type, and workflow ref, we ensure the package was built by the specific expected CI pipeline from a release tag. + +**Additional fields extracted but not directly verified:** The provenance parser also extracts `runDetails.builder.id` from the attestation. This is available in the `NpmProvenanceData` result for logging and diagnostics but is not currently used as a verification gate. + +### Step 4: Download and verify tarball integrity + +**Action:** +1. Run `npm pack @playwright/cli@{version}` to download the tarball +2. Compute SHA-512 hash of the downloaded tarball +3. Compare against the SRI integrity hash obtained in Step 1 + +**What this establishes:** That the tarball we have on disk is bit-for-bit identical to what the npm registry published for this version. + +**Trust basis:** Cryptographic hash comparison (SHA-512). If the hash matches, the content is the same regardless of how it was delivered. + +**Relationship to Step 3:** The Sigstore attestations verified in Step 3 are bound to the package version and its published content. The integrity hash in the registry packument is the canonical identifier for the tarball content. By verifying our tarball matches this hash, we establish that our tarball is the same artifact that the Sigstore attestations cover. + +### Step 5: Install globally from verified tarball + +**Action:** Run `npm install -g {tarballPath}` to install the verified tarball as a global tool. + +**What this establishes:** The tool is installed and available on the user's PATH. + +**Trust basis:** All preceding verification steps have passed. The tarball content has been verified against the registry's published hash (Step 4), the Sigstore attestations for that content are cryptographically valid (Step 3), and the attestations confirm the correct source repository, workflow, and build system (Step 3). + +### Step 6: Generate and mirror skill files + +**Action:** Run `playwright-cli install --skills` to generate agent skill files in the primary skill directory (`.claude/skills/playwright-cli/`), then mirror the skill directory to all other detected agent environment skill directories (e.g., `.github/skills/playwright-cli/`, `.opencode/skill/playwright-cli/`). The mirror is a full sync — files are created, updated, and stale files are removed so all environments have identical skill content. + +**What this establishes:** The Playwright CLI skill files are available for all configured agent environments. + +## Verification Chain Summary + +```text + ┌──────────────────────────────┐ + │ Hardcoded expectations │ + │ • Package: @playwright/cli │ + │ • Version range: >=0.1.1 │ + │ • Source: microsoft/ │ + │ playwright-cli │ + │ • Workflow: .github/ │ + │ workflows/publish.yml │ + │ • Build type: GitHub Actions │ + │ workflow/v1 │ + └──────────────┬────────────────┘ + │ + ┌──────────────▼────────────────┐ + │ Step 1: Resolve version + │ + │ integrity hash from registry │ + └──────────────┬────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ + ┌──────────▼──────────────┐ ┌─────────▼─────────┐ + │ Step 3: Sigstore verify │ │ Step 4: npm pack │ + │ + provenance checks │ │ + SHA-512 check │ + │ (in-process via Sigstore │ │ (tarball │ + │ .NET library + TUF) │ │ integrity) │ + └──────────┬───────────────┘ └─────────┬─────────┘ + │ │ + │ Attestation is authentic + │ Tarball matches + │ built from expected repo + │ published hash + │ expected pipeline │ + └────────────────────┬────────────────────┘ + │ + ┌──────────────▼────────────────┐ + │ Step 5: npm install -g │ + │ (from verified tarball) │ + └───────────────────────────────┘ +``` + +## Residual Risks + +### 1. Time-of-check-to-time-of-use (TOCTOU) + +**Risk:** The package could be replaced on the registry between our verification steps and the global install. + +**Mitigation:** We verify the SHA-512 hash of the tarball we actually install (Step 4), and we install from the local tarball file (not from the registry again). The verified tarball is the same file that gets installed. + +### 2. Transitive dependency attacks + +**Risk:** `@playwright/cli` has dependencies that could be compromised. + +**Mitigation:** The `--ignore-scripts` flag prevents execution of install scripts. However, the dependencies' code runs when the tool is invoked. This is partially mitigated by Sigstore attestations covering the dependency tree, but comprehensive supply chain verification of all transitive dependencies is out of scope. + +## Implementation Constants + +```csharp +internal const string PackageName = "@playwright/cli"; +internal const string VersionRange = ">=0.1.1"; +internal const string ExpectedSourceRepository = "https://github.com/microsoft/playwright-cli"; +internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml"; +internal const string ExpectedBuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; +internal const string NpmRegistryAttestationsBaseUrl = "https://registry.npmjs.org/-/npm/v1/attestations"; +internal const string SlsaProvenancePredicateType = "https://slsa.dev/provenance/v1"; +``` + +## Configuration + +Two break-glass configuration keys are available via `aspire config set`: + +| Key | Effect | +|---|---| +| `disablePlaywrightCliPackageValidation` | When `"true"`, skips all Sigstore, provenance, and integrity checks. Use only for debugging npm service issues. | +| `playwrightCliVersion` | When set, overrides the version range and pins to the specified exact version. | + +## Future Improvements + +1. **Pinned tarball hash** — Ship a known-good SRI hash with each Aspire release, eliminating the need to trust the registry for the hash at all. diff --git a/src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs b/src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs index 9794e7e6807..3a5d22424d9 100644 --- a/src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs +++ b/src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs @@ -10,6 +10,7 @@ internal sealed class AgentEnvironmentScanContext { private readonly List _applicators = []; private readonly HashSet _skillFileApplicatorPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _skillBaseDirectories = new(StringComparer.OrdinalIgnoreCase); /// /// Gets the working directory being scanned. @@ -24,31 +25,11 @@ internal sealed class AgentEnvironmentScanContext public required DirectoryInfo RepositoryRoot { get; init; } /// - /// Gets or sets a value indicating whether a Playwright applicator has been added. + /// Gets or sets a value indicating whether a Playwright CLI applicator has been added. /// This is used to ensure only one applicator for Playwright is added across all scanners. /// public bool PlaywrightApplicatorAdded { get; set; } - /// - /// Stores the Playwright configuration callbacks from each scanner. - /// These will be executed if the user selects to configure Playwright. - /// - private readonly List> _playwrightConfigurationCallbacks = []; - - /// - /// Adds a Playwright configuration callback for a specific environment. - /// - /// The callback to execute if Playwright is configured. - public void AddPlaywrightConfigurationCallback(Func callback) - { - _playwrightConfigurationCallbacks.Add(callback); - } - - /// - /// Gets all registered Playwright configuration callbacks. - /// - public IReadOnlyList> PlaywrightConfigurationCallbacks => _playwrightConfigurationCallbacks; - /// /// Checks if a skill file applicator has already been added for the specified path. /// @@ -82,4 +63,19 @@ public void AddApplicator(AgentEnvironmentApplicator applicator) /// Gets the collection of detected applicators. /// public IReadOnlyList Applicators => _applicators; + + /// + /// Registers a skill base directory for an agent environment (e.g., ".claude/skills", ".github/skills"). + /// These directories are used to mirror skill files across all detected agent environments. + /// + /// The relative path to the skill base directory from the repository root. + public void AddSkillBaseDirectory(string relativeSkillBaseDir) + { + _skillBaseDirectories.Add(relativeSkillBaseDir); + } + + /// + /// Gets the registered skill base directories for all detected agent environments. + /// + public IReadOnlyCollection SkillBaseDirectories => _skillBaseDirectories; } diff --git a/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs index 1b613bd6b25..b58b181df32 100644 --- a/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs +++ b/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Aspire.Cli.Agents.Playwright; using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; @@ -17,9 +18,11 @@ internal sealed class ClaudeCodeAgentEnvironmentScanner : IAgentEnvironmentScann private const string McpConfigFileName = ".mcp.json"; private const string AspireServerName = "aspire"; private static readonly string s_skillFilePath = Path.Combine(".claude", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); + private static readonly string s_skillBaseDirectory = Path.Combine(".claude", "skills"); private const string SkillFileDescription = "Create Aspire skill file (.claude/skills/aspire/SKILL.md)"; private readonly IClaudeCodeCliRunner _claudeCodeCliRunner; + private readonly PlaywrightCliInstaller _playwrightCliInstaller; private readonly CliExecutionContext _executionContext; private readonly ILogger _logger; @@ -27,14 +30,17 @@ internal sealed class ClaudeCodeAgentEnvironmentScanner : IAgentEnvironmentScann /// Initializes a new instance of . /// /// The Claude Code CLI runner for checking if Claude Code is installed. + /// The Playwright CLI installer for secure installation. /// The CLI execution context for accessing environment variables and settings. /// The logger for diagnostic output. - public ClaudeCodeAgentEnvironmentScanner(IClaudeCodeCliRunner claudeCodeCliRunner, CliExecutionContext executionContext, ILogger logger) + public ClaudeCodeAgentEnvironmentScanner(IClaudeCodeCliRunner claudeCodeCliRunner, PlaywrightCliInstaller playwrightCliInstaller, CliExecutionContext executionContext, ILogger logger) { ArgumentNullException.ThrowIfNull(claudeCodeCliRunner); + ArgumentNullException.ThrowIfNull(playwrightCliInstaller); ArgumentNullException.ThrowIfNull(executionContext); ArgumentNullException.ThrowIfNull(logger); _claudeCodeCliRunner = claudeCodeCliRunner; + _playwrightCliInstaller = playwrightCliInstaller; _executionContext = executionContext; _logger = logger; } @@ -68,18 +74,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok _logger.LogDebug("Aspire MCP server is already configured"); } - // Register Playwright configuration callback if not already configured - if (!HasPlaywrightServerConfigured(workspaceRoot)) - { - _logger.LogDebug("Registering Playwright MCP configuration callback for Claude Code"); - CommonAgentApplicators.AddPlaywrightConfigurationCallback( - context, - ct => ApplyPlaywrightMcpConfigurationAsync(workspaceRoot, ct)); - } - else - { - _logger.LogDebug("Playwright MCP server is already configured"); - } + // Register Playwright CLI installation applicator + CommonAgentApplicators.AddPlaywrightCliApplicator(context, _playwrightCliInstaller, s_skillBaseDirectory); // Try to add skill file applicator for Claude Code CommonAgentApplicators.TryAddSkillFileApplicator( @@ -109,18 +105,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok _logger.LogDebug("Aspire MCP server is already configured"); } - // Register Playwright configuration callback if not already configured - if (!HasPlaywrightServerConfigured(context.RepositoryRoot)) - { - _logger.LogDebug("Registering Playwright MCP configuration callback for Claude Code"); - CommonAgentApplicators.AddPlaywrightConfigurationCallback( - context, - ct => ApplyPlaywrightMcpConfigurationAsync(context.RepositoryRoot, ct)); - } - else - { - _logger.LogDebug("Playwright MCP server is already configured"); - } + // Register Playwright CLI installation applicator + CommonAgentApplicators.AddPlaywrightCliApplicator(context, _playwrightCliInstaller, s_skillBaseDirectory); // Try to add skill file applicator for Claude Code CommonAgentApplicators.TryAddSkillFileApplicator( @@ -179,16 +165,34 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok private static bool HasAspireServerConfigured(DirectoryInfo repoRoot) { var configFilePath = Path.Combine(repoRoot.FullName, McpConfigFileName); - return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcpServers", AspireServerName); - } - /// - /// Checks if the Playwright MCP server is already configured in the .mcp.json file. - /// - private static bool HasPlaywrightServerConfigured(DirectoryInfo repoRoot) - { - var configFilePath = Path.Combine(repoRoot.FullName, McpConfigFileName); - return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcpServers", "playwright"); + if (!File.Exists(configFilePath)) + { + return false; + } + + try + { + var content = File.ReadAllText(configFilePath); + var config = JsonNode.Parse(content)?.AsObject(); + + if (config is null) + { + return false; + } + + if (config.TryGetPropertyValue("mcpServers", out var serversNode) && serversNode is JsonObject servers) + { + return servers.ContainsKey(AspireServerName); + } + + return false; + } + catch (JsonException) + { + // If the JSON is malformed, assume aspire is not configured + return false; + } } /// @@ -231,33 +235,4 @@ private static async Task ApplyAspireMcpConfigurationAsync( await File.WriteAllTextAsync(configFilePath, jsonContent, cancellationToken); } - /// - /// Creates or updates the .mcp.json file at the repo root with Playwright MCP configuration. - /// - private static async Task ApplyPlaywrightMcpConfigurationAsync( - DirectoryInfo repoRoot, - CancellationToken cancellationToken) - { - var configFilePath = Path.Combine(repoRoot.FullName, McpConfigFileName); - var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken); - - // Ensure "mcpServers" object exists - if (!config.ContainsKey("mcpServers") || config["mcpServers"] is not JsonObject) - { - config["mcpServers"] = new JsonObject(); - } - - var servers = config["mcpServers"]!.AsObject(); - - // Add Playwright MCP server configuration - servers["playwright"] = new JsonObject - { - ["command"] = "npx", - ["args"] = new JsonArray("-y", "@playwright/mcp@latest") - }; - - // Write the updated config using AOT-compatible serialization - var jsonContent = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject); - await File.WriteAllTextAsync(configFilePath, jsonContent, cancellationToken); - } } diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index 3e4e6fb8365..fd825014621 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Agents.Playwright; + namespace Aspire.Cli.Agents; /// @@ -73,17 +75,20 @@ public static bool TryAddSkillFileApplicator( } /// - /// Tracks a detected environment and adds a single Playwright applicator if not already added. - /// This should be called by each scanner that detects an environment supporting Playwright. + /// Adds a single Playwright CLI installation applicator if not already added. + /// Called by scanners that detect an environment supporting Playwright. + /// The applicator uses to securely install the CLI and generate skill files. /// /// The scan context. - /// The callback to configure Playwright for this specific environment. - public static void AddPlaywrightConfigurationCallback( + /// The Playwright CLI installer that handles secure installation. + /// The relative path to the skill base directory for this agent environment (e.g., ".claude/skills", ".github/skills"). + public static void AddPlaywrightCliApplicator( AgentEnvironmentScanContext context, - Func configurationCallback) + PlaywrightCliInstaller installer, + string skillBaseDirectory) { - // Add this environment's Playwright configuration callback - context.AddPlaywrightConfigurationCallback(configurationCallback); + // Register the skill base directory so skill files can be mirrored to all environments + context.AddSkillBaseDirectory(skillBaseDirectory); // Only add the Playwright applicator prompt once across all environments if (context.PlaywrightApplicatorAdded) @@ -93,15 +98,8 @@ public static void AddPlaywrightConfigurationCallback( context.PlaywrightApplicatorAdded = true; context.AddApplicator(new AgentEnvironmentApplicator( - "Configure Playwright MCP server", - async ct => - { - // Execute all registered Playwright configuration callbacks - foreach (var callback in context.PlaywrightConfigurationCallbacks) - { - await callback(ct); - } - }, + "Install Playwright CLI for browser automation", + ct => installer.InstallAsync(context, ct), promptGroup: McpInitPromptGroup.AdditionalOptions, priority: 1)); } @@ -235,9 +233,9 @@ aspire run 1. _select apphost_; use this tool if working with multiple app hosts within a workspace. 2. _list apphosts_; use this tool to get details about active app hosts. - ## Playwright MCP server + ## Playwright CLI - The playwright MCP server has also been configured in this repository and you should use it to perform functional investigations of the resources defined in the app model as you work on the codebase. To get endpoints that can be used for navigation using the playwright MCP server use the list resources tool. + The Playwright CLI has been installed in this repository for browser automation. Use it to perform functional investigations of the resources defined in the app model as you work on the codebase. To get endpoints that can be used for navigation use the list resources tool. Run `playwright-cli --help` for available commands. ## Updating the app host diff --git a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs index cb40cf3da30..e5625598b11 100644 --- a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs +++ b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Aspire.Cli.Agents.Playwright; using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; @@ -17,9 +18,11 @@ internal sealed class CopilotCliAgentEnvironmentScanner : IAgentEnvironmentScann private const string McpConfigFileName = "mcp-config.json"; private const string AspireServerName = "aspire"; private static readonly string s_skillFilePath = Path.Combine(".github", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); + private static readonly string s_skillBaseDirectory = Path.Combine(".github", "skills"); private const string SkillFileDescription = "Create Aspire skill file (.github/skills/aspire/SKILL.md)"; private readonly ICopilotCliRunner _copilotCliRunner; + private readonly PlaywrightCliInstaller _playwrightCliInstaller; private readonly CliExecutionContext _executionContext; private readonly ILogger _logger; @@ -27,14 +30,17 @@ internal sealed class CopilotCliAgentEnvironmentScanner : IAgentEnvironmentScann /// Initializes a new instance of . /// /// The Copilot CLI runner for checking if Copilot CLI is installed. + /// The Playwright CLI installer for secure installation. /// The CLI execution context for accessing environment variables and settings. /// The logger for diagnostic output. - public CopilotCliAgentEnvironmentScanner(ICopilotCliRunner copilotCliRunner, CliExecutionContext executionContext, ILogger logger) + public CopilotCliAgentEnvironmentScanner(ICopilotCliRunner copilotCliRunner, PlaywrightCliInstaller playwrightCliInstaller, CliExecutionContext executionContext, ILogger logger) { ArgumentNullException.ThrowIfNull(copilotCliRunner); + ArgumentNullException.ThrowIfNull(playwrightCliInstaller); ArgumentNullException.ThrowIfNull(executionContext); ArgumentNullException.ThrowIfNull(logger); _copilotCliRunner = copilotCliRunner; + _playwrightCliInstaller = playwrightCliInstaller; _executionContext = executionContext; _logger = logger; } @@ -67,18 +73,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok _logger.LogDebug("Aspire MCP server is already configured in Copilot CLI"); } - // Register Playwright configuration callback if not already configured - if (!HasPlaywrightServerConfigured(homeDirectory)) - { - _logger.LogDebug("Registering Playwright MCP configuration callback for Copilot CLI"); - CommonAgentApplicators.AddPlaywrightConfigurationCallback( - context, - ct => ApplyPlaywrightMcpConfigurationAsync(homeDirectory, ct)); - } - else - { - _logger.LogDebug("Playwright MCP server is already configured in Copilot CLI"); - } + // Register Playwright CLI installation applicator + CommonAgentApplicators.AddPlaywrightCliApplicator(context, _playwrightCliInstaller, s_skillBaseDirectory); // Try to add skill file applicator for GitHub Copilot CommonAgentApplicators.TryAddSkillFileApplicator( @@ -115,18 +111,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok _logger.LogDebug("Aspire MCP server is already configured in Copilot CLI"); } - // Register Playwright configuration callback if not already configured - if (!HasPlaywrightServerConfigured(homeDirectory)) - { - _logger.LogDebug("Registering Playwright MCP configuration callback for Copilot CLI"); - CommonAgentApplicators.AddPlaywrightConfigurationCallback( - context, - ct => ApplyPlaywrightMcpConfigurationAsync(homeDirectory, ct)); - } - else - { - _logger.LogDebug("Playwright MCP server is already configured in Copilot CLI"); - } + // Register Playwright CLI installation applicator + CommonAgentApplicators.AddPlaywrightCliApplicator(context, _playwrightCliInstaller, s_skillBaseDirectory); // Try to add skill file applicator for GitHub Copilot CommonAgentApplicators.TryAddSkillFileApplicator( @@ -162,7 +148,34 @@ private static string GetMcpConfigFilePath(DirectoryInfo homeDirectory) private static bool HasAspireServerConfigured(DirectoryInfo homeDirectory) { var configFilePath = GetMcpConfigFilePath(homeDirectory); - return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcpServers", AspireServerName); + + if (!File.Exists(configFilePath)) + { + return false; + } + + try + { + var content = File.ReadAllText(configFilePath); + var config = JsonNode.Parse(content)?.AsObject(); + + if (config is null) + { + return false; + } + + if (config.TryGetPropertyValue("mcpServers", out var serversNode) && serversNode is JsonObject servers) + { + return servers.ContainsKey(AspireServerName); + } + + return false; + } + catch (JsonException) + { + // If the JSON is malformed, assume aspire is not configured + return false; + } } /// @@ -224,52 +237,4 @@ private static async Task ApplyMcpConfigurationAsync( await File.WriteAllTextAsync(configFilePath, jsonContent, cancellationToken); } - /// - /// Creates or updates the mcp-config.json file with Playwright MCP configuration. - /// - private static async Task ApplyPlaywrightMcpConfigurationAsync( - DirectoryInfo homeDirectory, - CancellationToken cancellationToken) - { - var configDirectory = GetCopilotConfigDirectory(homeDirectory); - var configFilePath = GetMcpConfigFilePath(homeDirectory); - - // Ensure the .copilot directory exists - if (!Directory.Exists(configDirectory)) - { - Directory.CreateDirectory(configDirectory); - } - - var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken); - - // Ensure "mcpServers" object exists - if (!config.ContainsKey("mcpServers") || config["mcpServers"] is not JsonObject) - { - config["mcpServers"] = new JsonObject(); - } - - var servers = config["mcpServers"]!.AsObject(); - - // Add Playwright MCP server configuration - servers["playwright"] = new JsonObject - { - ["type"] = "local", - ["command"] = "npx", - ["args"] = new JsonArray("-y", "@playwright/mcp@latest"), - ["tools"] = new JsonArray("*") - }; - - // Write the updated config using AOT-compatible serialization - var jsonContent = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject); - await File.WriteAllTextAsync(configFilePath, jsonContent, cancellationToken); - } - - /// - /// Checks if the Playwright MCP server is already configured in the mcp-config.json file. - /// - private static bool HasPlaywrightServerConfigured(DirectoryInfo homeDirectory) - { - var configFilePath = GetMcpConfigFilePath(homeDirectory); - return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcpServers", "playwright"); - } } diff --git a/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs index 1674a690e29..98450ccae21 100644 --- a/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs +++ b/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Aspire.Cli.Agents.Playwright; using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; @@ -16,21 +17,26 @@ internal sealed class OpenCodeAgentEnvironmentScanner : IAgentEnvironmentScanner private const string OpenCodeConfigFileName = "opencode.jsonc"; private const string AspireServerName = "aspire"; private static readonly string s_skillFilePath = Path.Combine(".opencode", "skill", CommonAgentApplicators.AspireSkillName, "SKILL.md"); + private static readonly string s_skillBaseDirectory = Path.Combine(".opencode", "skill"); private const string SkillFileDescription = "Create Aspire skill file (.opencode/skill/aspire/SKILL.md)"; private readonly IOpenCodeCliRunner _openCodeCliRunner; + private readonly PlaywrightCliInstaller _playwrightCliInstaller; private readonly ILogger _logger; /// /// Initializes a new instance of . /// /// The OpenCode CLI runner for checking if OpenCode is installed. + /// The Playwright CLI installer for secure installation. /// The logger for diagnostic output. - public OpenCodeAgentEnvironmentScanner(IOpenCodeCliRunner openCodeCliRunner, ILogger logger) + public OpenCodeAgentEnvironmentScanner(IOpenCodeCliRunner openCodeCliRunner, PlaywrightCliInstaller playwrightCliInstaller, ILogger logger) { ArgumentNullException.ThrowIfNull(openCodeCliRunner); + ArgumentNullException.ThrowIfNull(playwrightCliInstaller); ArgumentNullException.ThrowIfNull(logger); _openCodeCliRunner = openCodeCliRunner; + _playwrightCliInstaller = playwrightCliInstaller; _logger = logger; } @@ -62,18 +68,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok _logger.LogDebug("Aspire MCP server is already configured"); } - // Add Playwright configuration callback if not already configured - if (!HasPlaywrightServerConfigured(configFilePath)) - { - _logger.LogDebug("Registering Playwright MCP configuration callback for OpenCode"); - CommonAgentApplicators.AddPlaywrightConfigurationCallback( - context, - ct => ApplyPlaywrightMcpConfigurationAsync(configDirectory, ct)); - } - else - { - _logger.LogDebug("Playwright MCP server is already configured"); - } + // Register Playwright CLI installation applicator + CommonAgentApplicators.AddPlaywrightCliApplicator(context, _playwrightCliInstaller, s_skillBaseDirectory); // Try to add skill file applicator for OpenCode CommonAgentApplicators.TryAddSkillFileApplicator( @@ -95,10 +91,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok _logger.LogDebug("Adding OpenCode applicator to create new opencode.jsonc at: {ConfigDirectory}", configDirectory.FullName); context.AddApplicator(CreateApplicator(configDirectory)); - // Register Playwright configuration callback - CommonAgentApplicators.AddPlaywrightConfigurationCallback( - context, - ct => ApplyPlaywrightMcpConfigurationAsync(configDirectory, ct)); + // Register Playwright CLI installation applicator + CommonAgentApplicators.AddPlaywrightCliApplicator(context, _playwrightCliInstaller, s_skillBaseDirectory); // Try to add skill file applicator for OpenCode CommonAgentApplicators.TryAddSkillFileApplicator( @@ -121,7 +115,32 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok /// True if the aspire server is already configured, false otherwise. private static bool HasAspireServerConfigured(string configFilePath) { - return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcp", AspireServerName, RemoveJsonComments); + try + { + var content = File.ReadAllText(configFilePath); + + // Remove single-line comments for parsing (JSONC support) + content = RemoveJsonComments(content); + + var config = JsonNode.Parse(content)?.AsObject(); + + if (config is null) + { + return false; + } + + if (config.TryGetPropertyValue("mcp", out var mcpNode) && mcpNode is JsonObject mcp) + { + return mcp.ContainsKey(AspireServerName); + } + + return false; + } + catch (JsonException) + { + // If the JSON is malformed, assume aspire is not configured + return false; + } } /// @@ -180,8 +199,11 @@ private static async Task ApplyMcpConfigurationAsync( var configFilePath = Path.Combine(configDirectory.FullName, OpenCodeConfigFileName); var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken, RemoveJsonComments); - // Ensure schema is set for new files - config.TryAdd("$schema", "https://opencode.ai/config.json"); + // Ensure schema is set for new configs + if (!config.ContainsKey("$schema")) + { + config["$schema"] = "https://opencode.ai/config.json"; + } // Ensure "mcp" object exists if (!config.ContainsKey("mcp") || config["mcp"] is not JsonObject) @@ -204,45 +226,4 @@ private static async Task ApplyMcpConfigurationAsync( await File.WriteAllTextAsync(configFilePath, jsonOutput, cancellationToken); } - /// - /// Creates or updates the opencode.jsonc file with Playwright MCP configuration. - /// - private static async Task ApplyPlaywrightMcpConfigurationAsync( - DirectoryInfo configDirectory, - CancellationToken cancellationToken) - { - var configFilePath = Path.Combine(configDirectory.FullName, OpenCodeConfigFileName); - var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken, RemoveJsonComments); - - // Ensure schema is set for new files - config.TryAdd("$schema", "https://opencode.ai/config.json"); - - // Ensure "mcp" object exists - if (!config.ContainsKey("mcp") || config["mcp"] is not JsonObject) - { - config["mcp"] = new JsonObject(); - } - - var mcp = config["mcp"]!.AsObject(); - - // Add Playwright MCP server configuration - mcp["playwright"] = new JsonObject - { - ["type"] = "local", - ["command"] = new JsonArray("npx", "-y", "@playwright/mcp@latest"), - ["enabled"] = true - }; - - // Write the updated config using AOT-compatible serialization - var jsonOutput = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject); - await File.WriteAllTextAsync(configFilePath, jsonOutput, cancellationToken); - } - - /// - /// Checks if the Playwright MCP server is already configured in the opencode.jsonc file. - /// - private static bool HasPlaywrightServerConfigured(string configFilePath) - { - return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcp", "playwright", RemoveJsonComments); - } } diff --git a/src/Aspire.Cli/Agents/Playwright/IPlaywrightCliRunner.cs b/src/Aspire.Cli/Agents/Playwright/IPlaywrightCliRunner.cs new file mode 100644 index 00000000000..047c73f6f85 --- /dev/null +++ b/src/Aspire.Cli/Agents/Playwright/IPlaywrightCliRunner.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Semver; + +namespace Aspire.Cli.Agents.Playwright; + +/// +/// Interface for running playwright-cli commands. +/// +internal interface IPlaywrightCliRunner +{ + /// + /// Gets the version of the playwright-cli if it is installed. + /// + /// A token to cancel the operation. + /// The version of the playwright-cli, or null if it is not installed. + Task GetVersionAsync(CancellationToken cancellationToken); + + /// + /// Installs Playwright CLI skill files into the workspace. + /// + /// A token to cancel the operation. + /// True if skill installation succeeded, false otherwise. + Task InstallSkillsAsync(CancellationToken cancellationToken); +} diff --git a/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs new file mode 100644 index 00000000000..23dcfb35060 --- /dev/null +++ b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs @@ -0,0 +1,352 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using Aspire.Cli.Interaction; +using Aspire.Cli.Npm; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Semver; + +namespace Aspire.Cli.Agents.Playwright; + +/// +/// Orchestrates secure installation of the Playwright CLI with supply chain verification. +/// +internal sealed class PlaywrightCliInstaller( + INpmRunner npmRunner, + INpmProvenanceChecker provenanceChecker, + IPlaywrightCliRunner playwrightCliRunner, + IInteractionService interactionService, + IConfiguration configuration, + ILogger logger) +{ + /// + /// The npm package name for the Playwright CLI. + /// + internal const string PackageName = "@playwright/cli"; + + /// + /// The version range to resolve. Accepts any version from 0.1.1 onwards. + /// + internal const string VersionRange = ">=0.1.1"; + + /// + /// The expected source repository for provenance verification. + /// + internal const string ExpectedSourceRepository = "https://github.com/microsoft/playwright-cli"; + + /// + /// The expected workflow file path in the source repository. + /// + internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml"; + + /// + /// The expected SLSA build type, which identifies GitHub Actions as the CI system + /// and implicitly confirms the OIDC token issuer is https://token.actions.githubusercontent.com. + /// + internal const string ExpectedBuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; + + /// + /// The name of the playwright-cli skill directory. + /// + internal const string PlaywrightCliSkillName = "playwright-cli"; + + /// + /// The primary skill base directory where playwright-cli installs skills. + /// + internal static readonly string s_primarySkillBaseDirectory = Path.Combine(".claude", "skills"); + + /// + /// Configuration key that disables package validation when set to "true". + /// This is a break-glass mechanism for debugging npm service issues and must never be the default. + /// + internal const string DisablePackageValidationKey = "disablePlaywrightCliPackageValidation"; + + /// + /// Configuration key that overrides the version to install. When set, the specified + /// exact version is used instead of resolving the latest from the version range. + /// + internal const string VersionOverrideKey = "playwrightCliVersion"; + + /// + /// Installs the Playwright CLI with supply chain verification and generates skill files. + /// + /// The agent environment scan context containing detected skill directories. + /// A token to cancel the operation. + /// True if installation succeeded or was skipped (already up-to-date), false on failure. + public async Task InstallAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken) + { + return await interactionService.ShowStatusAsync( + "Installing Playwright CLI...", + () => InstallCoreAsync(context, cancellationToken)); + } + + private async Task InstallCoreAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken) + { + // Step 1: Resolve the target version and integrity hash from the npm registry. + var versionOverride = configuration[VersionOverrideKey]; + var effectiveRange = !string.IsNullOrEmpty(versionOverride) ? versionOverride : VersionRange; + + if (!string.IsNullOrEmpty(versionOverride)) + { + logger.LogDebug("Using version override from '{ConfigKey}': {Version}", VersionOverrideKey, versionOverride); + } + + logger.LogDebug("Resolving {Package}@{Range} from npm registry", PackageName, effectiveRange); + var packageInfo = await npmRunner.ResolvePackageAsync(PackageName, effectiveRange, cancellationToken); + + if (packageInfo is null) + { + logger.LogWarning("Failed to resolve {Package}@{Range} from npm registry. Is npm installed?", PackageName, VersionRange); + return false; + } + + logger.LogDebug("Resolved {Package}@{Version} with integrity {Integrity}", PackageName, packageInfo.Version, packageInfo.Integrity); + + // Step 2: Check if a suitable version is already installed. + var installedVersion = await playwrightCliRunner.GetVersionAsync(cancellationToken); + if (installedVersion is not null) + { + var comparison = SemVersion.ComparePrecedence(installedVersion, packageInfo.Version); + if (comparison >= 0) + { + logger.LogDebug( + "playwright-cli {InstalledVersion} is already installed (target: {TargetVersion}), skipping installation", + installedVersion, + packageInfo.Version); + + // Still install skills in case they're missing. + var skillsInstalled = await playwrightCliRunner.InstallSkillsAsync(cancellationToken); + if (skillsInstalled) + { + MirrorSkillFiles(context); + } + return skillsInstalled; + } + + logger.LogDebug( + "Upgrading playwright-cli from {InstalledVersion} to {TargetVersion}", + installedVersion, + packageInfo.Version); + } + + // Check break-glass configuration to bypass package validation. + var validationDisabled = string.Equals(configuration[DisablePackageValidationKey], "true", StringComparison.OrdinalIgnoreCase); + if (validationDisabled) + { + logger.LogWarning( + "Package validation is disabled via '{ConfigKey}'. " + + "Sigstore attestation, provenance, and integrity checks will be skipped. " + + "This should only be used for debugging npm service issues.", + DisablePackageValidationKey); + } + + if (!validationDisabled) + { + // Step 3: Verify provenance via Sigstore bundle verification and SLSA attestation checks. + // This cryptographically verifies the Sigstore bundle (Fulcio CA, Rekor tlog, OIDC identity) + // and then checks the provenance fields (source repo, workflow, build type, ref). + logger.LogDebug("Verifying provenance for {Package}@{Version}", PackageName, packageInfo.Version); + var provenanceResult = await provenanceChecker.VerifyProvenanceAsync( + PackageName, + packageInfo.Version.ToString(), + ExpectedSourceRepository, + ExpectedWorkflowPath, + ExpectedBuildType, + refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && + string.Equals(refInfo.Name, $"v{packageInfo.Version}", StringComparison.Ordinal), + cancellationToken, + sriIntegrity: packageInfo.Integrity); + + if (!provenanceResult.IsVerified) + { + logger.LogWarning( + "Provenance verification failed for {Package}@{Version}: {Outcome}. Expected source repository: {ExpectedRepo}", + PackageName, + packageInfo.Version, + provenanceResult.Outcome, + ExpectedSourceRepository); + return false; + } + + logger.LogDebug( + "Provenance verification passed for {Package}@{Version} (source: {SourceRepo})", + PackageName, + packageInfo.Version, + provenanceResult.Provenance?.SourceRepository); + } + + // Step 4: Download the tarball via npm pack. + var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-playwright-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + logger.LogDebug("Downloading {Package}@{Version} to {TempDir}", PackageName, packageInfo.Version, tempDir); + var tarballPath = await npmRunner.PackAsync(PackageName, packageInfo.Version.ToString(), tempDir, cancellationToken); + + if (tarballPath is null) + { + logger.LogWarning("Failed to download {Package}@{Version}", PackageName, packageInfo.Version); + return false; + } + + // Step 5: Verify the downloaded tarball's SHA-512 hash matches the SRI integrity value. + if (!validationDisabled && !VerifyIntegrity(tarballPath, packageInfo.Integrity)) + { + logger.LogWarning( + "Integrity verification failed for {Package}@{Version}. The downloaded package may have been tampered with.", + PackageName, + packageInfo.Version); + return false; + } + + if (!validationDisabled) + { + logger.LogDebug("Integrity verification passed for {TarballPath}", tarballPath); + } + + // Step 6: Install globally from the verified tarball. + logger.LogDebug("Installing {Package}@{Version} globally", PackageName, packageInfo.Version); + var installSuccess = await npmRunner.InstallGlobalAsync(tarballPath, cancellationToken); + + if (!installSuccess) + { + logger.LogWarning("Failed to install {Package}@{Version} globally", PackageName, packageInfo.Version); + return false; + } + + // Step 7: Generate skill files. + logger.LogDebug("Generating Playwright CLI skill files"); + var skillsResult = await playwrightCliRunner.InstallSkillsAsync(cancellationToken); + if (skillsResult) + { + MirrorSkillFiles(context); + } + return skillsResult; + } + finally + { + // Clean up temporary directory. + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch (IOException ex) + { + logger.LogDebug(ex, "Failed to clean up temporary directory: {TempDir}", tempDir); + } + } + } + + /// + /// Mirrors the playwright-cli skill directory from the primary location to all other + /// detected agent environment skill directories so that every configured environment + /// has an identical copy of the skill files. + /// + private void MirrorSkillFiles(AgentEnvironmentScanContext context) + { + var repoRoot = context.RepositoryRoot.FullName; + var primarySkillDir = Path.Combine(repoRoot, s_primarySkillBaseDirectory, PlaywrightCliSkillName); + + if (!Directory.Exists(primarySkillDir)) + { + logger.LogDebug("Primary skill directory does not exist: {PrimarySkillDir}", primarySkillDir); + return; + } + + foreach (var skillBaseDir in context.SkillBaseDirectories) + { + // Skip the primary directory — it's the source + if (string.Equals(skillBaseDir, s_primarySkillBaseDirectory, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var targetSkillDir = Path.Combine(repoRoot, skillBaseDir, PlaywrightCliSkillName); + + try + { + SyncDirectory(primarySkillDir, targetSkillDir); + logger.LogDebug("Mirrored playwright-cli skills to {TargetDir}", targetSkillDir); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Failed to mirror playwright-cli skills to {TargetDir}", targetSkillDir); + } + } + } + + /// + /// Synchronizes the contents of the source directory to the target directory, + /// creating, updating, and removing files so the target matches the source exactly. + /// + internal static void SyncDirectory(string sourceDir, string targetDir) + { + Directory.CreateDirectory(targetDir); + + // Copy all files from source to target + foreach (var sourceFile in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDir, sourceFile); + var targetFile = Path.Combine(targetDir, relativePath); + + var targetFileDir = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrEmpty(targetFileDir)) + { + Directory.CreateDirectory(targetFileDir); + } + + File.Copy(sourceFile, targetFile, overwrite: true); + } + + // Remove files in target that don't exist in source + if (Directory.Exists(targetDir)) + { + foreach (var targetFile in Directory.GetFiles(targetDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(targetDir, targetFile); + var sourceFile = Path.Combine(sourceDir, relativePath); + + if (!File.Exists(sourceFile)) + { + File.Delete(targetFile); + } + } + + // Remove empty directories in target + foreach (var dir in Directory.GetDirectories(targetDir, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length)) + { + if (Directory.Exists(dir) && Directory.GetFileSystemEntries(dir).Length == 0) + { + Directory.Delete(dir); + } + } + } + } + + /// + /// Verifies that the SHA-512 hash of the file matches the SRI integrity string. + /// + internal static bool VerifyIntegrity(string filePath, string sriIntegrity) + { + // SRI format: "sha512-" + if (!sriIntegrity.StartsWith("sha512-", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var expectedHash = sriIntegrity["sha512-".Length..]; + + using var stream = File.OpenRead(filePath); + var hashBytes = SHA512.HashData(stream); + var actualHash = Convert.ToBase64String(hashBytes); + + return string.Equals(expectedHash, actualHash, StringComparison.Ordinal); + } +} diff --git a/src/Aspire.Cli/Agents/Playwright/PlaywrightCliRunner.cs b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliRunner.cs new file mode 100644 index 00000000000..13fd127c4bb --- /dev/null +++ b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliRunner.cs @@ -0,0 +1,128 @@ +// 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; +using Microsoft.Extensions.Logging; +using Semver; + +namespace Aspire.Cli.Agents.Playwright; + +/// +/// Runs playwright-cli commands. +/// +internal sealed class PlaywrightCliRunner(ILogger logger) : IPlaywrightCliRunner +{ + /// + public async Task GetVersionAsync(CancellationToken cancellationToken) + { + var executablePath = PathLookupHelper.FindFullPathFromPath("playwright-cli"); + if (executablePath is null) + { + logger.LogDebug("playwright-cli is not installed or not found in PATH"); + return null; + } + + try + { + var startInfo = new ProcessStartInfo(executablePath, "--version") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var errorOutput = await errorTask.ConfigureAwait(false); + logger.LogDebug("playwright-cli --version returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + return null; + } + + var output = await outputTask.ConfigureAwait(false); + var versionString = output.Trim().Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); + + if (string.IsNullOrEmpty(versionString)) + { + logger.LogDebug("playwright-cli returned empty version output"); + return null; + } + + if (versionString.StartsWith('v') || versionString.StartsWith('V')) + { + versionString = versionString[1..]; + } + + if (SemVersion.TryParse(versionString, SemVersionStyles.Any, out var version)) + { + logger.LogDebug("Found playwright-cli version: {Version}", version); + return version; + } + + logger.LogDebug("Could not parse playwright-cli version from output: {Output}", versionString); + return null; + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + logger.LogDebug(ex, "playwright-cli is not installed or not found in PATH"); + return null; + } + } + + /// + public async Task InstallSkillsAsync(CancellationToken cancellationToken) + { + var executablePath = PathLookupHelper.FindFullPathFromPath("playwright-cli"); + if (executablePath is null) + { + logger.LogDebug("playwright-cli is not installed or not found in PATH"); + return false; + } + + try + { + var startInfo = new ProcessStartInfo(executablePath) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + startInfo.ArgumentList.Add("install"); + startInfo.ArgumentList.Add("--skills"); + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var errorOutput = await errorTask.ConfigureAwait(false); + logger.LogDebug("playwright-cli install --skills returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + return false; + } + + var output = await outputTask.ConfigureAwait(false); + logger.LogDebug("playwright-cli install --skills output: {Output}", output.Trim()); + return true; + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + logger.LogDebug(ex, "Failed to run playwright-cli install --skills"); + return false; + } + } +} diff --git a/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs index 22bbd1885e4..27d24c1dd7e 100644 --- a/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs +++ b/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Aspire.Cli.Agents.Playwright; using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; @@ -17,9 +18,11 @@ internal sealed class VsCodeAgentEnvironmentScanner : IAgentEnvironmentScanner private const string McpConfigFileName = "mcp.json"; private const string AspireServerName = "aspire"; private static readonly string s_skillFilePath = Path.Combine(".github", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); + private static readonly string s_skillBaseDirectory = Path.Combine(".github", "skills"); private const string SkillFileDescription = "Create Aspire skill file (.github/skills/aspire/SKILL.md)"; private readonly IVsCodeCliRunner _vsCodeCliRunner; + private readonly PlaywrightCliInstaller _playwrightCliInstaller; private readonly CliExecutionContext _executionContext; private readonly ILogger _logger; @@ -27,14 +30,17 @@ internal sealed class VsCodeAgentEnvironmentScanner : IAgentEnvironmentScanner /// Initializes a new instance of . /// /// The VS Code CLI runner for checking if VS Code is installed. + /// The Playwright CLI installer for secure installation. /// The CLI execution context for accessing environment variables and settings. /// The logger for diagnostic output. - public VsCodeAgentEnvironmentScanner(IVsCodeCliRunner vsCodeCliRunner, CliExecutionContext executionContext, ILogger logger) + public VsCodeAgentEnvironmentScanner(IVsCodeCliRunner vsCodeCliRunner, PlaywrightCliInstaller playwrightCliInstaller, CliExecutionContext executionContext, ILogger logger) { ArgumentNullException.ThrowIfNull(vsCodeCliRunner); + ArgumentNullException.ThrowIfNull(playwrightCliInstaller); ArgumentNullException.ThrowIfNull(executionContext); ArgumentNullException.ThrowIfNull(logger); _vsCodeCliRunner = vsCodeCliRunner; + _playwrightCliInstaller = playwrightCliInstaller; _executionContext = executionContext; _logger = logger; } @@ -64,18 +70,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok _logger.LogDebug("Aspire MCP server is already configured in .vscode/mcp.json"); } - // Register Playwright configuration callback if not already configured - if (!HasPlaywrightServerConfigured(vsCodeFolder)) - { - _logger.LogDebug("Registering Playwright MCP configuration callback for .vscode folder"); - CommonAgentApplicators.AddPlaywrightConfigurationCallback( - context, - ct => ApplyPlaywrightMcpConfigurationAsync(vsCodeFolder, ct)); - } - else - { - _logger.LogDebug("Playwright MCP server is already configured in .vscode/mcp.json"); - } + // Register Playwright CLI installation applicator + CommonAgentApplicators.AddPlaywrightCliApplicator(context, _playwrightCliInstaller, s_skillBaseDirectory); // Try to add skill file applicator for GitHub Copilot CommonAgentApplicators.TryAddSkillFileApplicator( @@ -93,10 +89,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok _logger.LogDebug("Adding VS Code applicator for new .vscode folder at: {VsCodeFolder}", targetVsCodeFolder.FullName); context.AddApplicator(CreateAspireApplicator(targetVsCodeFolder)); - // Register Playwright configuration callback - CommonAgentApplicators.AddPlaywrightConfigurationCallback( - context, - ct => ApplyPlaywrightMcpConfigurationAsync(targetVsCodeFolder, ct)); + // Register Playwright CLI installation applicator + CommonAgentApplicators.AddPlaywrightCliApplicator(context, _playwrightCliInstaller, s_skillBaseDirectory); // Try to add skill file applicator for GitHub Copilot CommonAgentApplicators.TryAddSkillFileApplicator( @@ -205,16 +199,34 @@ private bool HasVsCodeEnvironmentVariables() private static bool HasAspireServerConfigured(DirectoryInfo vsCodeFolder) { var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); - return McpConfigFileHelper.HasServerConfigured(mcpConfigPath, "servers", AspireServerName); - } - /// - /// Checks if the Playwright MCP server is already configured in the mcp.json file. - /// - private static bool HasPlaywrightServerConfigured(DirectoryInfo vsCodeFolder) - { - var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); - return McpConfigFileHelper.HasServerConfigured(mcpConfigPath, "servers", "playwright"); + if (!File.Exists(mcpConfigPath)) + { + return false; + } + + try + { + var content = File.ReadAllText(mcpConfigPath); + var config = JsonNode.Parse(content)?.AsObject(); + + if (config is null) + { + return false; + } + + if (config.TryGetPropertyValue("servers", out var serversNode) && serversNode is JsonObject servers) + { + return servers.ContainsKey(AspireServerName); + } + + return false; + } + catch (JsonException) + { + // If the JSON is malformed, assume aspire is not configured + return false; + } } /// @@ -264,40 +276,4 @@ private static async Task ApplyAspireMcpConfigurationAsync( await File.WriteAllTextAsync(mcpConfigPath, jsonContent, cancellationToken); } - /// - /// Creates or updates the mcp.json file in the .vscode folder with Playwright MCP configuration. - /// - private static async Task ApplyPlaywrightMcpConfigurationAsync( - DirectoryInfo vsCodeFolder, - CancellationToken cancellationToken) - { - // Ensure the .vscode folder exists - if (!vsCodeFolder.Exists) - { - vsCodeFolder.Create(); - } - - var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); - var config = await McpConfigFileHelper.ReadConfigAsync(mcpConfigPath, cancellationToken); - - // Ensure "servers" object exists - if (!config.ContainsKey("servers") || config["servers"] is not JsonObject) - { - config["servers"] = new JsonObject(); - } - - var servers = config["servers"]!.AsObject(); - - // Add Playwright MCP server configuration - servers["playwright"] = new JsonObject - { - ["type"] = "stdio", - ["command"] = "npx", - ["args"] = new JsonArray("-y", "@playwright/mcp@latest") - }; - - // Write the updated config with indentation using AOT-compatible serialization - var jsonContent = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject); - await File.WriteAllTextAsync(mcpConfigPath, jsonContent, cancellationToken); - } } diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 70859bb694a..6fbe84981a7 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -16,6 +16,9 @@ in BackchannelJsonSerializerContext.cs. Suppress until MCP graduates these types. --> $(NoWarn);CS1591;MCPEXP001 true + + false false Size $(DefineConstants);CLI @@ -54,6 +57,8 @@ + + diff --git a/src/Aspire.Cli/Npm/INpmProvenanceChecker.cs b/src/Aspire.Cli/Npm/INpmProvenanceChecker.cs new file mode 100644 index 00000000000..6b3a7616c24 --- /dev/null +++ b/src/Aspire.Cli/Npm/INpmProvenanceChecker.cs @@ -0,0 +1,194 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Npm; + +/// +/// Represents the outcome of a provenance verification check. +/// Each value corresponds to a specific gate in the verification process. +/// +internal enum ProvenanceVerificationOutcome +{ + /// + /// All checks passed and the source repository matches the expected value. + /// + Verified, + + /// + /// Failed to fetch attestation data from the npm registry (network error or non-success HTTP status). + /// + AttestationFetchFailed, + + /// + /// The attestation response could not be parsed as valid JSON. + /// + AttestationParseFailed, + + /// + /// No SLSA provenance attestation was found in the registry response. + /// + SlsaProvenanceNotFound, + + /// + /// The DSSE envelope payload could not be decoded from the attestation bundle. + /// + PayloadDecodeFailed, + + /// + /// The source repository could not be extracted from the provenance statement. + /// + SourceRepositoryNotFound, + + /// + /// The attested source repository does not match the expected value. + /// + SourceRepositoryMismatch, + + /// + /// The attested workflow path does not match the expected value. + /// + WorkflowMismatch, + + /// + /// The SLSA build type does not match the expected GitHub Actions build type, + /// indicating the package was not built by the expected CI system. + /// + BuildTypeMismatch, + + /// + /// The workflow ref did not pass the caller-provided validation callback, + /// indicating the build was not triggered from the expected release tag. + /// + WorkflowRefMismatch +} + +/// +/// Represents the deserialized provenance data extracted from an SLSA attestation. +/// +internal sealed class NpmProvenanceData +{ + /// + /// Gets the source repository URL from the attestation (e.g., "https://github.com/microsoft/playwright-cli"). + /// + public string? SourceRepository { get; init; } + + /// + /// Gets the workflow file path from the attestation (e.g., ".github/workflows/publish.yml"). + /// + public string? WorkflowPath { get; init; } + + /// + /// Gets the builder ID URI from the attestation (e.g., "https://github.com/actions/runner/github-hosted"). + /// + public string? BuilderId { get; init; } + + /// + /// Gets the workflow reference (e.g., "refs/tags/v0.1.1"). + /// + public string? WorkflowRef { get; init; } + + /// + /// Gets the SLSA build type URI which identifies the CI system used to build the package + /// (e.g., "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1" for GitHub Actions). + /// This implicitly confirms the OIDC token issuer (e.g., https://token.actions.githubusercontent.com). + /// + public string? BuildType { get; init; } +} + +/// +/// Represents the result of a provenance verification check. +/// +internal sealed class ProvenanceVerificationResult +{ + /// + /// Gets the outcome of the verification, indicating which gate passed or failed. + /// + public required ProvenanceVerificationOutcome Outcome { get; init; } + + /// + /// Gets the deserialized provenance data, if available. May be partially populated + /// depending on how far verification progressed before failure. + /// + public NpmProvenanceData? Provenance { get; init; } + + /// + /// Gets a value indicating whether the verification succeeded. + /// + public bool IsVerified => Outcome is ProvenanceVerificationOutcome.Verified; +} + +/// +/// Represents a parsed workflow ref from an SLSA provenance attestation. +/// A workflow ref like refs/tags/v0.1.1 is decomposed into its kind (e.g., "tags") +/// and name (e.g., "v0.1.1") to enable structured validation by callers. +/// +/// The original unmodified ref string (e.g., refs/tags/v0.1.1). +/// The ref kind (e.g., "tags", "heads"). Extracted from the second segment of the ref path. +/// The ref name after the kind prefix (e.g., "v0.1.1", "main"). +internal sealed record WorkflowRefInfo(string Raw, string Kind, string Name) +{ + /// + /// Attempts to parse a git ref string into its structured components. + /// Expected format: refs/{kind}/{name} (e.g., refs/tags/v0.1.1). + /// + /// The raw ref string to parse. + /// The parsed if successful. + /// true if the ref was successfully parsed; false otherwise. + public static bool TryParse(string? refString, out WorkflowRefInfo? refInfo) + { + refInfo = null; + + if (string.IsNullOrEmpty(refString)) + { + return false; + } + + // Expected format: refs/{kind}/{name...} + // The name can contain slashes (e.g., refs/tags/@scope/pkg@1.0.0) + if (!refString.StartsWith("refs/", StringComparison.Ordinal)) + { + return false; + } + + var afterRefs = refString["refs/".Length..]; + var slashIndex = afterRefs.IndexOf('/'); + if (slashIndex <= 0 || slashIndex == afterRefs.Length - 1) + { + return false; + } + + var kind = afterRefs[..slashIndex]; + var name = afterRefs[(slashIndex + 1)..]; + refInfo = new WorkflowRefInfo(refString, kind, name); + return true; + } +} + +/// +/// Verifies npm package provenance by checking SLSA attestations from the npm registry. +/// +internal interface INpmProvenanceChecker +{ + /// + /// Verifies that the SLSA provenance attestation for a package was built from the expected source repository, + /// using the expected workflow, and with the expected build system. + /// + /// The npm package name (e.g., "@playwright/cli"). + /// The exact version to verify. + /// The expected source repository URL (e.g., "https://github.com/microsoft/playwright-cli"). + /// The expected workflow file path (e.g., ".github/workflows/publish.yml"). + /// The expected SLSA build type URI identifying the CI system. + /// + /// An optional callback that validates the parsed workflow ref. The callback receives a + /// with the ref decomposed into its kind and name. If null, the workflow ref gate is skipped. + /// If the callback returns false, verification fails with . + /// + /// A token to cancel the operation. + /// + /// An optional SRI integrity string (e.g., "sha512-...") for the package tarball. + /// When provided, implementations that perform cryptographic verification can verify + /// that the attestation covers this specific artifact digest. + /// + /// A indicating the outcome and any extracted provenance data. + Task VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null); +} diff --git a/src/Aspire.Cli/Npm/INpmRunner.cs b/src/Aspire.Cli/Npm/INpmRunner.cs new file mode 100644 index 00000000000..7bd3eb27934 --- /dev/null +++ b/src/Aspire.Cli/Npm/INpmRunner.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Semver; + +namespace Aspire.Cli.Npm; + +/// +/// Represents the result of resolving an npm package version. +/// +internal sealed class NpmPackageInfo +{ + /// + /// Gets the resolved version of the package. + /// + public required SemVersion Version { get; init; } + + /// + /// Gets the SRI integrity hash (e.g., "sha512-...") for the package tarball. + /// + public required string Integrity { get; init; } +} + +/// +/// Interface for running npm CLI commands. +/// +internal interface INpmRunner +{ + /// + /// Resolves a package version and integrity hash from the npm registry. + /// + /// The npm package name (e.g., "@playwright/cli"). + /// The version range to resolve (e.g., "0.1"). + /// A token to cancel the operation. + /// The resolved package info, or null if the package was not found or npm is not installed. + Task ResolvePackageAsync(string packageName, string versionRange, CancellationToken cancellationToken); + + /// + /// Downloads a package tarball to a temporary directory using npm pack. + /// + /// The npm package name (e.g., "@playwright/cli"). + /// The exact version to download. + /// The directory to download the tarball into. + /// A token to cancel the operation. + /// The full path to the downloaded .tgz file, or null if the operation failed. + Task PackAsync(string packageName, string version, string outputDirectory, CancellationToken cancellationToken); + + /// + /// Verifies Sigstore attestation signatures for a package by installing it into a temporary + /// project and running npm audit signatures. This is necessary because npm audit signatures + /// requires a project context (node_modules + package-lock.json) that doesn't exist for + /// global tool installations. + /// + /// The npm package name to verify (e.g., "@playwright/cli"). + /// The exact version to verify. + /// A token to cancel the operation. + /// True if the audit passed, false otherwise. + Task AuditSignaturesAsync(string packageName, string version, CancellationToken cancellationToken); + + /// + /// Installs a package globally from a local tarball file. + /// + /// The path to the .tgz file to install. + /// A token to cancel the operation. + /// True if the installation succeeded, false otherwise. + Task InstallGlobalAsync(string tarballPath, CancellationToken cancellationToken); +} diff --git a/src/Aspire.Cli/Npm/NpmProvenanceChecker.cs b/src/Aspire.Cli/Npm/NpmProvenanceChecker.cs new file mode 100644 index 00000000000..423b5e1e686 --- /dev/null +++ b/src/Aspire.Cli/Npm/NpmProvenanceChecker.cs @@ -0,0 +1,233 @@ +// 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; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Npm; + +/// +/// Verifies npm package provenance by fetching and parsing SLSA attestations from the npm registry API. +/// +internal sealed class NpmProvenanceChecker(HttpClient httpClient, ILogger logger) : INpmProvenanceChecker +{ + internal const string NpmRegistryAttestationsBaseUrl = "https://registry.npmjs.org/-/npm/v1/attestations"; + internal const string SlsaProvenancePredicateType = "https://slsa.dev/provenance/v1"; + + /// + public async Task VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null) + { + // Gate 1: Fetch attestations from the npm registry. + string json; + try + { + var encodedPackage = Uri.EscapeDataString(packageName); + var url = $"{NpmRegistryAttestationsBaseUrl}/{encodedPackage}@{version}"; + + logger.LogDebug("Fetching attestations from {Url}", url); + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("Failed to fetch attestations: HTTP {StatusCode}", response.StatusCode); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; + } + + json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + logger.LogDebug(ex, "Failed to fetch attestations for {Package}@{Version}", packageName, version); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; + } + + // Gate 2: Parse the attestation JSON and extract provenance data. + NpmProvenanceData provenance; + try + { + var parseResult = ParseProvenance(json); + if (parseResult is null) + { + return new ProvenanceVerificationResult { Outcome = parseResult?.Outcome ?? ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; + } + + provenance = parseResult.Value.Provenance; + if (parseResult.Value.Outcome is not ProvenanceVerificationOutcome.Verified) + { + return new ProvenanceVerificationResult + { + Outcome = parseResult.Value.Outcome, + Provenance = provenance + }; + } + } + catch (JsonException ex) + { + logger.LogDebug(ex, "Failed to parse attestation response for {Package}@{Version}", packageName, version); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + } + + logger.LogDebug("SLSA provenance source repository: {SourceRepository}", provenance.SourceRepository); + + // Gate 3: Verify the source repository matches. + if (!string.Equals(provenance.SourceRepository, expectedSourceRepository, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning( + "Provenance verification failed: expected source repository {Expected} but attestation says {Actual}", + expectedSourceRepository, + provenance.SourceRepository); + + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch, + Provenance = provenance + }; + } + + // Gate 4: Verify the workflow path matches. + if (!string.Equals(provenance.WorkflowPath, expectedWorkflowPath, StringComparison.Ordinal)) + { + logger.LogWarning( + "Provenance verification failed: expected workflow path {Expected} but attestation says {Actual}", + expectedWorkflowPath, + provenance.WorkflowPath); + + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.WorkflowMismatch, + Provenance = provenance + }; + } + + // Gate 5: Verify the build type matches (confirms CI system and OIDC token issuer). + if (!string.Equals(provenance.BuildType, expectedBuildType, StringComparison.Ordinal)) + { + logger.LogWarning( + "Provenance verification failed: expected build type {Expected} but attestation says {Actual}", + expectedBuildType, + provenance.BuildType); + + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.BuildTypeMismatch, + Provenance = provenance + }; + } + + // Gate 6: Verify the workflow ref using the caller-provided validation callback. + // Different packages use different tag formats (e.g., "v0.1.1", "0.1.1", "@scope/pkg@0.1.1"), + // so the caller decides what constitutes a valid ref. + if (validateWorkflowRef is not null) + { + if (!WorkflowRefInfo.TryParse(provenance.WorkflowRef, out var refInfo) || refInfo is null) + { + logger.LogWarning( + "Provenance verification failed: could not parse workflow ref {WorkflowRef}", + provenance.WorkflowRef); + + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.WorkflowRefMismatch, + Provenance = provenance + }; + } + + if (!validateWorkflowRef(refInfo)) + { + logger.LogWarning( + "Provenance verification failed: workflow ref {WorkflowRef} did not pass validation", + provenance.WorkflowRef); + + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.WorkflowRefMismatch, + Provenance = provenance + }; + } + } + + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.Verified, + Provenance = provenance + }; + } + + /// + /// Parses provenance data from the npm attestation API response. + /// + internal static (NpmProvenanceData Provenance, ProvenanceVerificationOutcome Outcome)? ParseProvenance(string attestationJson) + { + var doc = JsonNode.Parse(attestationJson); + var attestations = doc?["attestations"]?.AsArray(); + + if (attestations is null || attestations.Count == 0) + { + return (new NpmProvenanceData(), ProvenanceVerificationOutcome.SlsaProvenanceNotFound); + } + + foreach (var attestation in attestations) + { + var predicateType = attestation?["predicateType"]?.GetValue(); + if (!string.Equals(predicateType, SlsaProvenancePredicateType, StringComparison.Ordinal)) + { + continue; + } + + // The SLSA provenance is in the DSSE envelope payload, base64-encoded. + var payload = attestation?["bundle"]?["dsseEnvelope"]?["payload"]?.GetValue(); + if (payload is null) + { + return (new NpmProvenanceData(), ProvenanceVerificationOutcome.PayloadDecodeFailed); + } + + byte[] decodedBytes; + try + { + decodedBytes = Convert.FromBase64String(payload); + } + catch (FormatException) + { + return (new NpmProvenanceData(), ProvenanceVerificationOutcome.PayloadDecodeFailed); + } + + var statement = JsonNode.Parse(decodedBytes); + var predicate = statement?["predicate"]; + var buildDefinition = predicate?["buildDefinition"]; + var workflow = buildDefinition + ?["externalParameters"] + ?["workflow"]; + + var repository = workflow?["repository"]?.GetValue(); + var workflowPath = workflow?["path"]?.GetValue(); + var workflowRef = workflow?["ref"]?.GetValue(); + + var builderId = predicate + ?["runDetails"] + ?["builder"] + ?["id"] + ?.GetValue(); + + var buildType = buildDefinition?["buildType"]?.GetValue(); + + var provenance = new NpmProvenanceData + { + SourceRepository = repository, + WorkflowPath = workflowPath, + WorkflowRef = workflowRef, + BuilderId = builderId, + BuildType = buildType + }; + + if (repository is null) + { + return (provenance, ProvenanceVerificationOutcome.SourceRepositoryNotFound); + } + + return (provenance, ProvenanceVerificationOutcome.Verified); + } + + return (new NpmProvenanceData(), ProvenanceVerificationOutcome.SlsaProvenanceNotFound); + } +} diff --git a/src/Aspire.Cli/Npm/NpmRunner.cs b/src/Aspire.Cli/Npm/NpmRunner.cs new file mode 100644 index 00000000000..5a3fee4e991 --- /dev/null +++ b/src/Aspire.Cli/Npm/NpmRunner.cs @@ -0,0 +1,277 @@ +// 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; +using Microsoft.Extensions.Logging; +using Semver; + +namespace Aspire.Cli.Npm; + +/// +/// Runs npm CLI commands for package management operations. +/// +internal sealed class NpmRunner(ILogger logger) : INpmRunner +{ + /// + public async Task ResolvePackageAsync(string packageName, string versionRange, CancellationToken cancellationToken) + { + var npmPath = FindNpmPath(); + if (npmPath is null) + { + return null; + } + + // Resolve version: npm view @ version + var versionOutput = await RunNpmCommandAsync( + npmPath, + ["view", $"{packageName}@{versionRange}", "version"], + cancellationToken); + + if (versionOutput is null) + { + return null; + } + + var versionString = versionOutput.Trim(); + if (!SemVersion.TryParse(versionString, SemVersionStyles.Any, out var version)) + { + logger.LogDebug("Could not parse npm version from output: {Output}", versionString); + return null; + } + + // Resolve integrity hash: npm view @ dist.integrity + var integrityOutput = await RunNpmCommandAsync( + npmPath, + ["view", $"{packageName}@{version}", "dist.integrity"], + cancellationToken); + + if (string.IsNullOrWhiteSpace(integrityOutput)) + { + logger.LogDebug("Could not resolve integrity hash for {Package}@{Version}", packageName, version); + return null; + } + + return new NpmPackageInfo + { + Version = version, + Integrity = integrityOutput.Trim() + }; + } + + /// + public async Task PackAsync(string packageName, string version, string outputDirectory, CancellationToken cancellationToken) + { + var npmPath = FindNpmPath(); + if (npmPath is null) + { + return null; + } + + var output = await RunNpmCommandAsync( + npmPath, + ["pack", $"{packageName}@{version}", "--pack-destination", outputDirectory], + cancellationToken); + + if (output is null) + { + return null; + } + + // npm pack outputs the filename of the created tarball + var filename = output.Trim().Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + if (string.IsNullOrWhiteSpace(filename)) + { + logger.LogDebug("npm pack returned empty filename"); + return null; + } + + var tarballPath = Path.Combine(outputDirectory, filename); + if (!File.Exists(tarballPath)) + { + logger.LogDebug("npm pack output file not found: {Path}", tarballPath); + return null; + } + + return tarballPath; + } + + /// + public async Task AuditSignaturesAsync(string packageName, string version, CancellationToken cancellationToken) + { + var npmPath = FindNpmPath(); + if (npmPath is null) + { + return false; + } + + // npm audit signatures requires a project context (node_modules + package-lock.json). + // For global tool installs there is no project, so we create a temporary one. + // The package must be installed from the registry (not a local tarball) because + // npm audit signatures skips packages with "resolved: file:..." in the lockfile. + var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-npm-audit-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + // Create minimal package.json + var packageJson = Path.Combine(tempDir, "package.json"); + await File.WriteAllTextAsync( + packageJson, + """{"name":"aspire-verify","version":"1.0.0","private":true}""", + cancellationToken).ConfigureAwait(false); + + // Install the package from the registry to get proper attestation metadata + var installOutput = await RunNpmCommandInDirectoryAsync( + npmPath, + ["install", $"{packageName}@{version}", "--ignore-scripts"], + tempDir, + cancellationToken); + + if (installOutput is null) + { + logger.LogDebug("Failed to install {Package}@{Version} into temporary project for audit", packageName, version); + return false; + } + + // Run npm audit signatures in the temporary project directory + var auditOutput = await RunNpmCommandInDirectoryAsync( + npmPath, + ["audit", "signatures"], + tempDir, + cancellationToken); + + return auditOutput is not null; + } + finally + { + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch (IOException ex) + { + logger.LogDebug(ex, "Failed to clean up temporary audit directory: {TempDir}", tempDir); + } + } + } + + /// + public async Task InstallGlobalAsync(string tarballPath, CancellationToken cancellationToken) + { + var npmPath = FindNpmPath(); + if (npmPath is null) + { + return false; + } + + var output = await RunNpmCommandAsync( + npmPath, + ["install", "-g", tarballPath], + cancellationToken); + + return output is not null; + } + + private string? FindNpmPath() + { + var npmPath = PathLookupHelper.FindFullPathFromPath("npm"); + if (npmPath is null) + { + logger.LogDebug("npm is not installed or not found in PATH"); + } + + return npmPath; + } + + private async Task RunNpmCommandInDirectoryAsync(string npmPath, string[] args, string workingDirectory, CancellationToken cancellationToken) + { + var argsString = string.Join(" ", args); + logger.LogDebug("Running npm {Args} in {WorkingDirectory}", argsString, workingDirectory); + + try + { + var startInfo = new ProcessStartInfo(npmPath) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory + }; + + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var errorOutput = await errorTask.ConfigureAwait(false); + logger.LogDebug("npm {Args} returned non-zero exit code {ExitCode}: {Error}", argsString, process.ExitCode, errorOutput.Trim()); + return null; + } + + return await outputTask.ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + logger.LogDebug(ex, "Failed to run npm {Args}", argsString); + return null; + } + } + + private async Task RunNpmCommandAsync(string npmPath, string[] args, CancellationToken cancellationToken) + { + var argsString = string.Join(" ", args); + logger.LogDebug("Running npm {Args}", argsString); + + try + { + var startInfo = new ProcessStartInfo(npmPath) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var errorOutput = await errorTask.ConfigureAwait(false); + logger.LogDebug("npm {Args} returned non-zero exit code {ExitCode}: {Error}", argsString, process.ExitCode, errorOutput.Trim()); + return null; + } + + return await outputTask.ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + logger.LogDebug(ex, "Failed to run npm {Args}", argsString); + return null; + } + } +} diff --git a/src/Aspire.Cli/Npm/SigstoreNpmProvenanceChecker.cs b/src/Aspire.Cli/Npm/SigstoreNpmProvenanceChecker.cs new file mode 100644 index 00000000000..03f46bcb6cb --- /dev/null +++ b/src/Aspire.Cli/Npm/SigstoreNpmProvenanceChecker.cs @@ -0,0 +1,393 @@ +// 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; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Sigstore; + +namespace Aspire.Cli.Npm; + +/// +/// The parsed result of an npm attestation response, containing both the Sigstore bundle +/// and the provenance data extracted from the DSSE envelope in a single pass. +/// +internal sealed class NpmAttestationParseResult +{ + /// + /// Gets the outcome of the parse operation. + /// + public required ProvenanceVerificationOutcome Outcome { get; init; } + + /// + /// Gets the raw Sigstore bundle JSON node for deserialization by the Sigstore library. + /// + public JsonNode? BundleNode { get; init; } + + /// + /// Gets the provenance data extracted from the DSSE envelope payload. + /// + public NpmProvenanceData? Provenance { get; init; } +} + +/// +/// Verifies npm package provenance by cryptographically verifying Sigstore bundles +/// from the npm registry attestations API using the Sigstore .NET library. +/// +internal sealed class SigstoreNpmProvenanceChecker(HttpClient httpClient, ILogger logger) : INpmProvenanceChecker +{ + internal const string NpmRegistryAttestationsBaseUrl = "https://registry.npmjs.org/-/npm/v1/attestations"; + internal const string SlsaProvenancePredicateType = "https://slsa.dev/provenance/v1"; + + /// + public async Task VerifyProvenanceAsync( + string packageName, + string version, + string expectedSourceRepository, + string expectedWorkflowPath, + string expectedBuildType, + Func? validateWorkflowRef, + CancellationToken cancellationToken, + string? sriIntegrity = null) + { + var json = await FetchAttestationJsonAsync(packageName, version, cancellationToken).ConfigureAwait(false); + if (json is null) + { + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; + } + + var attestation = ParseAttestation(json); + if (attestation.Outcome is not ProvenanceVerificationOutcome.Verified) + { + return new ProvenanceVerificationResult { Outcome = attestation.Outcome, Provenance = attestation.Provenance }; + } + + var sigstoreFailure = await VerifySigstoreBundleAsync( + attestation.BundleNode!, expectedSourceRepository, sriIntegrity, + packageName, version, cancellationToken).ConfigureAwait(false); + if (sigstoreFailure is not null) + { + return sigstoreFailure; + } + + return VerifyProvenanceFields( + attestation.Provenance!, expectedSourceRepository, expectedWorkflowPath, + expectedBuildType, validateWorkflowRef); + } + + /// + /// Fetches the attestation JSON from the npm registry for the given package and version. + /// + private async Task FetchAttestationJsonAsync( + string packageName, string version, CancellationToken cancellationToken) + { + try + { + var encodedPackage = Uri.EscapeDataString(packageName); + var url = $"{NpmRegistryAttestationsBaseUrl}/{encodedPackage}@{version}"; + + logger.LogDebug("Fetching attestations from {Url}", url); + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("Failed to fetch attestations: HTTP {StatusCode}", response.StatusCode); + return null; + } + + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + logger.LogDebug(ex, "Failed to fetch attestations for {Package}@{Version}", packageName, version); + return null; + } + } + + /// + /// Parses the npm attestation JSON in a single pass, extracting both the Sigstore bundle + /// node and the provenance data from the SLSA provenance attestation's DSSE envelope. + /// + internal static NpmAttestationParseResult ParseAttestation(string attestationJson) + { + JsonNode? doc; + try + { + doc = JsonNode.Parse(attestationJson); + } + catch (JsonException) + { + return new NpmAttestationParseResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + } + + var attestations = doc?["attestations"]?.AsArray(); + if (attestations is null || attestations.Count == 0) + { + return new NpmAttestationParseResult { Outcome = ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; + } + + foreach (var attestation in attestations) + { + var predicateType = attestation?["predicateType"]?.GetValue(); + if (!string.Equals(predicateType, SlsaProvenancePredicateType, StringComparison.Ordinal)) + { + continue; + } + + var bundleNode = attestation?["bundle"]; + if (bundleNode is null) + { + return new NpmAttestationParseResult { Outcome = ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; + } + + var payload = bundleNode["dsseEnvelope"]?["payload"]?.GetValue(); + if (payload is null) + { + return new NpmAttestationParseResult + { + Outcome = ProvenanceVerificationOutcome.PayloadDecodeFailed, + BundleNode = bundleNode + }; + } + + byte[] decodedBytes; + try + { + decodedBytes = Convert.FromBase64String(payload); + } + catch (FormatException) + { + return new NpmAttestationParseResult + { + Outcome = ProvenanceVerificationOutcome.PayloadDecodeFailed, + BundleNode = bundleNode + }; + } + + var provenance = ParseProvenanceFromStatement(decodedBytes); + if (provenance is null) + { + return new NpmAttestationParseResult + { + Outcome = ProvenanceVerificationOutcome.AttestationParseFailed, + BundleNode = bundleNode + }; + } + + var outcome = provenance.SourceRepository is null + ? ProvenanceVerificationOutcome.SourceRepositoryNotFound + : ProvenanceVerificationOutcome.Verified; + + return new NpmAttestationParseResult + { + Outcome = outcome, + BundleNode = bundleNode, + Provenance = provenance + }; + } + + return new NpmAttestationParseResult { Outcome = ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; + } + + /// + /// Extracts provenance fields from a decoded in-toto statement. + /// + internal static NpmProvenanceData? ParseProvenanceFromStatement(byte[] statementBytes) + { + try + { + var statement = JsonNode.Parse(statementBytes); + var predicate = statement?["predicate"]; + var buildDefinition = predicate?["buildDefinition"]; + var workflow = buildDefinition?["externalParameters"]?["workflow"]; + + return new NpmProvenanceData + { + SourceRepository = workflow?["repository"]?.GetValue(), + WorkflowPath = workflow?["path"]?.GetValue(), + WorkflowRef = workflow?["ref"]?.GetValue(), + BuilderId = predicate?["runDetails"]?["builder"]?["id"]?.GetValue(), + BuildType = buildDefinition?["buildType"]?.GetValue() + }; + } + catch (JsonException) + { + return null; + } + } + + /// + /// Cryptographically verifies the Sigstore bundle using the Sigstore library. + /// Checks the Fulcio certificate chain, Rekor transparency log inclusion, and OIDC identity. + /// + /// null if verification succeeded; otherwise a failure result. + private async Task VerifySigstoreBundleAsync( + JsonNode bundleNode, + string expectedSourceRepository, + string? sriIntegrity, + string packageName, + string version, + CancellationToken cancellationToken) + { + var bundleJson = bundleNode.ToJsonString(); + SigstoreBundle bundle; + try + { + bundle = SigstoreBundle.Deserialize(bundleJson); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to deserialize Sigstore bundle for {Package}@{Version}", packageName, version); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + } + + if (!TryParseGitHubOwnerRepo(expectedSourceRepository, out var owner, out var repo)) + { + logger.LogWarning("Could not parse GitHub owner/repo from expected source repository: {ExpectedSourceRepository}", expectedSourceRepository); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch }; + } + + var verifier = new SigstoreVerifier(); + var policy = new VerificationPolicy + { + CertificateIdentity = CertificateIdentity.ForGitHubActions(owner, repo) + }; + + try + { + bool success; + VerificationResult? result; + + if (sriIntegrity is not null && sriIntegrity.StartsWith("sha512-", StringComparison.OrdinalIgnoreCase)) + { + var hashBase64 = sriIntegrity["sha512-".Length..]; + var digestBytes = Convert.FromBase64String(hashBase64); + + (success, result) = await verifier.TryVerifyDigestAsync( + digestBytes, HashAlgorithmType.Sha512, bundle, policy, cancellationToken).ConfigureAwait(false); + } + else + { + var payloadBase64 = bundleNode["dsseEnvelope"]?["payload"]?.GetValue(); + if (payloadBase64 is null) + { + logger.LogDebug("No DSSE payload found in bundle for {Package}@{Version}", packageName, version); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.PayloadDecodeFailed }; + } + + var payloadBytes = Convert.FromBase64String(payloadBase64); + (success, result) = await verifier.TryVerifyAsync( + payloadBytes, bundle, policy, cancellationToken).ConfigureAwait(false); + } + + if (!success) + { + logger.LogWarning( + "Sigstore verification failed for {Package}@{Version}: {FailureReason}", + packageName, version, result?.FailureReason); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + } + + logger.LogDebug( + "Sigstore verification passed for {Package}@{Version}. Signed by: {Signer}", + packageName, version, result?.SignerIdentity?.SubjectAlternativeName); + + return null; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Sigstore verification threw an exception for {Package}@{Version}", packageName, version); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + } + } + + /// + /// Verifies that the extracted provenance fields match the expected values. + /// Checks source repository, workflow path, build type, and workflow ref in order. + /// + internal static ProvenanceVerificationResult VerifyProvenanceFields( + NpmProvenanceData provenance, + string expectedSourceRepository, + string expectedWorkflowPath, + string expectedBuildType, + Func? validateWorkflowRef) + { + if (!string.Equals(provenance.SourceRepository, expectedSourceRepository, StringComparison.OrdinalIgnoreCase)) + { + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch, + Provenance = provenance + }; + } + + if (!string.Equals(provenance.WorkflowPath, expectedWorkflowPath, StringComparison.Ordinal)) + { + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.WorkflowMismatch, + Provenance = provenance + }; + } + + if (!string.Equals(provenance.BuildType, expectedBuildType, StringComparison.Ordinal)) + { + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.BuildTypeMismatch, + Provenance = provenance + }; + } + + if (validateWorkflowRef is not null) + { + if (!WorkflowRefInfo.TryParse(provenance.WorkflowRef, out var refInfo) || refInfo is null) + { + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.WorkflowRefMismatch, + Provenance = provenance + }; + } + + if (!validateWorkflowRef(refInfo)) + { + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.WorkflowRefMismatch, + Provenance = provenance + }; + } + } + + return new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.Verified, + Provenance = provenance + }; + } + + /// + /// Parses a GitHub repository URL into owner and repo components. + /// + internal static bool TryParseGitHubOwnerRepo(string repositoryUrl, out string owner, out string repo) + { + owner = string.Empty; + repo = string.Empty; + + if (!Uri.TryCreate(repositoryUrl, UriKind.Absolute, out var uri)) + { + return false; + } + + var segments = uri.AbsolutePath.Trim('/').Split('/'); + if (segments.Length < 2) + { + return false; + } + + owner = segments[0]; + repo = segments[1]; + return true; + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index a812cc07b05..1a52021b572 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -317,6 +317,12 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Npm and Playwright CLI operations. + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + // Agent environment detection. builder.Services.AddSingleton(); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/Aspire.Cli/Resources/McpCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/McpCommandStrings.Designer.cs index e347734caf0..69cb7ac3cfe 100644 --- a/src/Aspire.Cli/Resources/McpCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/McpCommandStrings.Designer.cs @@ -106,7 +106,7 @@ internal static string InitCommand_ConfigurationComplete { } /// - /// Looks up a localized string similar to Pre-configure Playwright MCP server?. + /// Looks up a localized string similar to Install Playwright CLI for browser automation?. /// internal static string InitCommand_ConfigurePlaywrightPrompt { get { diff --git a/src/Aspire.Cli/Resources/McpCommandStrings.resx b/src/Aspire.Cli/Resources/McpCommandStrings.resx index 3ecae62c8a9..e5624ec847e 100644 --- a/src/Aspire.Cli/Resources/McpCommandStrings.resx +++ b/src/Aspire.Cli/Resources/McpCommandStrings.resx @@ -100,6 +100,6 @@ Create agent environment specific instruction files? - Pre-configure Playwright MCP server? + Install Playwright CLI for browser automation? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.cs.xlf index 8971878c481..3dbd0abdd6c 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.cs.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Chcete předem nakonfigurovat server Playwright MCP? + Install Playwright CLI for browser automation? + Chcete předem nakonfigurovat server Playwright MCP? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.de.xlf index 520d43c5744..56a88563b4d 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.de.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Playwright MCP-Server vorkonfigurieren? + Install Playwright CLI for browser automation? + Playwright MCP-Server vorkonfigurieren? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.es.xlf index ce9e8371562..ca14d274ada 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.es.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - ¿Configurar previamente el servidor Playwright MCP? + Install Playwright CLI for browser automation? + ¿Configurar previamente el servidor Playwright MCP? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.fr.xlf index 3538476e647..b230ee0b11c 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.fr.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Préconfigurer le serveur de MCP Playwright ? + Install Playwright CLI for browser automation? + Préconfigurer le serveur de MCP Playwright ? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.it.xlf index 4d0349bcd93..6e68d5f22c0 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.it.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Preconfigurare il server MCP di Playwright? + Install Playwright CLI for browser automation? + Preconfigurare il server MCP di Playwright? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ja.xlf index fc8afa58fbe..1eb7bd95b3f 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ja.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Playwright MCP サーバーを事前に構成しますか? + Install Playwright CLI for browser automation? + Playwright MCP サーバーを事前に構成しますか? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ko.xlf index ff56c690c0c..1b2e7bbd80c 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ko.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Playwright MCP 서버를 미리 구성할까요? + Install Playwright CLI for browser automation? + Playwright MCP 서버를 미리 구성할까요? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pl.xlf index 44e5c0d32b0..e6067823e2e 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pl.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Wstępnie skonfigurować serwer MCP Playwright? + Install Playwright CLI for browser automation? + Wstępnie skonfigurować serwer MCP Playwright? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pt-BR.xlf index b6a7a8f1bca..ffe5cdd3c25 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pt-BR.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Pré-configurar o servidor MCP do Playwright? + Install Playwright CLI for browser automation? + Pré-configurar o servidor MCP do Playwright? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ru.xlf index 961b13be6e8..cb399ce4199 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ru.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Предварительно настроить сервер MCP Playwright? + Install Playwright CLI for browser automation? + Предварительно настроить сервер MCP Playwright? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.tr.xlf index c5fe900d32b..e1d76049542 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.tr.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - Playwright MCP sunucusunda ön yapılandırma yapılsın mı? + Install Playwright CLI for browser automation? + Playwright MCP sunucusunda ön yapılandırma yapılsın mı? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hans.xlf index b037114d647..0faafc9773f 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hans.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - 预配置 Playwright MCP 服务器? + Install Playwright CLI for browser automation? + 预配置 Playwright MCP 服务器? diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hant.xlf index 77d090531fe..ef26fe6d098 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hant.xlf @@ -28,8 +28,8 @@ - Pre-configure Playwright MCP server? - 預先設定 Playwright MCP 伺服器? + Install Playwright CLI for browser automation? + 預先設定 Playwright MCP 伺服器? diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs new file mode 100644 index 00000000000..5e658c25d3f --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs @@ -0,0 +1,160 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Aspire.TestUtilities; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end test verifying that the Playwright CLI installation flow works correctly +/// through aspire agent init, including npm provenance verification and skill file generation. +/// +[OuterloopTest("Requires npm and network access to install @playwright/cli from the npm registry")] +public sealed class PlaywrightCliInstallTests(ITestOutputHelper output) +{ + /// + /// Verifies the full Playwright CLI installation lifecycle: + /// 1. Playwright CLI is not initially installed + /// 2. An Aspire project is created + /// 3. aspire agent init is run with Claude Code environment selected + /// 4. Playwright CLI is installed and available on PATH + /// 5. The .claude/skills/playwright-cli/SKILL.md skill file is generated + /// + [Fact] + public async Task AgentInit_InstallsPlaywrightCli_AndGeneratesSkillFiles() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( + nameof(AgentInit_InstallsPlaywrightCli_AndGeneratesSkillFiles)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Patterns for prompt detection + var workspacePrompt = new CellPatternSearcher().Find("workspace:"); + var agentEnvPrompt = new CellPatternSearcher().Find("agent environments"); + var additionalOptionsPrompt = new CellPatternSearcher().Find("additional options"); + var playwrightOption = new CellPatternSearcher().Find("Install Playwright CLI"); + var configComplete = new CellPatternSearcher().Find("configuration complete"); + var skillFileExists = new CellPatternSearcher().Find("SKILL.md"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Step 1: Verify playwright-cli is not installed. + sequenceBuilder + .Type("playwright-cli --version 2>&1 || true") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 2: Create an Aspire project (accept all defaults). + var starterAppTemplate = new CellPatternSearcher().FindPattern("> Starter App"); + var projectNamePrompt = new CellPatternSearcher().Find("Enter the project name"); + var outputPathPrompt = new CellPatternSearcher().Find("Enter the output path"); + var urlsPrompt = new CellPatternSearcher().Find("*.dev.localhost URLs"); + var redisPrompt = new CellPatternSearcher().Find("Use Redis Cache"); + var testProjectPrompt = new CellPatternSearcher().Find("Do you want to create a test project?"); + + sequenceBuilder + .Type("aspire new") + .Enter() + .WaitUntil(s => starterAppTemplate.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select Starter App template + .WaitUntil(s => projectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type("TestProject") + .Enter() + .WaitUntil(s => outputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => urlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default URL setting + .WaitUntil(s => redisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default Redis setting + .WaitUntil(s => testProjectPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default test project setting + .WaitForSuccessPrompt(counter); + + // Step 3: Navigate into the project and create .claude folder to trigger Claude Code detection. + sequenceBuilder + .Type("cd TestProject && mkdir -p .claude") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 4: Run aspire agent init. + // First prompt: workspace path + sequenceBuilder + .Type("aspire agent init") + .Enter() + .WaitUntil(s => workspacePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Wait(500) + .Enter(); // Accept default workspace path + + // Second prompt: agent environments (select Claude Code) + sequenceBuilder + .WaitUntil(s => agentEnvPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Type(" ") // Toggle first option (Claude Code) + .Enter(); + + // Third prompt: additional options (select Playwright CLI installation) + // Aspire skill file (priority 0) appears first, Playwright CLI (priority 1) second. + sequenceBuilder + .WaitUntil(s => additionalOptionsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .WaitUntil(s => playwrightOption.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type(" ") // Toggle first option (Aspire skill file) + .Key(Hex1b.Input.Hex1bKey.DownArrow) // Move to Playwright CLI option + .Type(" ") // Toggle Playwright CLI option + .Enter(); + + // Wait for installation to complete (this downloads from npm, can take a while) + sequenceBuilder + .WaitUntil(s => configComplete.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Step 5: Verify playwright-cli is now installed. + sequenceBuilder + .Type("playwright-cli --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 6: Verify the skill file was generated. + sequenceBuilder + .Type("ls .claude/skills/playwright-cli/SKILL.md") + .Enter() + .WaitUntil(s => skillFileExists.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs index a0a0185e653..a67a5428925 100644 --- a/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs @@ -2,8 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; using Aspire.Cli.Agents; using Aspire.Cli.Agents.ClaudeCode; +using Aspire.Cli.Agents.Playwright; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Logging.Abstractions; using Semver; @@ -24,7 +27,7 @@ public async Task ApplyAsync_WithMalformedMcpJson_ThrowsInvalidOperationExceptio var claudeCodeCliRunner = new FakeClaudeCodeCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -52,7 +55,7 @@ public async Task ApplyAsync_WithEmptyMcpJson_ThrowsInvalidOperationException() var claudeCodeCliRunner = new FakeClaudeCodeCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -78,7 +81,7 @@ public async Task ApplyAsync_WithMalformedMcpJson_DoesNotOverwriteFile() var claudeCodeCliRunner = new FakeClaudeCodeCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -118,6 +121,17 @@ private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingD homeDirectory: workingDirectory); } + private static PlaywrightCliInstaller CreatePlaywrightCliInstaller() + { + return new PlaywrightCliInstaller( + new FakeNpmRunner(), + new FakeNpmProvenanceChecker(), + new FakePlaywrightCliRunner(), + new TestConsoleInteractionService(), + new ConfigurationBuilder().Build(), + NullLogger.Instance); + } + private sealed class FakeClaudeCodeCliRunner(SemVersion? version) : IClaudeCodeCliRunner { public Task GetVersionAsync(CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs index 3a791f21202..398305f509b 100644 --- a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; using System.Text.Json.Nodes; using Aspire.Cli.Agents; using Aspire.Cli.Agents.CopilotCli; +using Aspire.Cli.Agents.Playwright; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.Logging.Abstractions; using Semver; @@ -19,12 +22,12 @@ public async Task ScanAsync_WhenCopilotCliInstalled_ReturnsApplicator() using var workspace = TemporaryWorkspace.Create(outputHelper); var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions + // Scanner adds applicators for: Aspire MCP, Playwright CLI, and agent instructions Assert.NotEmpty(context.Applicators); Assert.Contains(context.Applicators, a => a.Description.Contains("GitHub Copilot CLI")); } @@ -40,12 +43,12 @@ public async Task ApplyAsync_CreatesMcpConfigJsonWithCorrectConfiguration() // Create a scanner that writes to a known test location var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions + // Scanner adds applicators for: Aspire MCP, Playwright CLI, and agent instructions Assert.NotEmpty(context.Applicators); var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); @@ -109,7 +112,7 @@ public async Task ApplyAsync_PreservesExistingMcpConfigContent() var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -128,7 +131,7 @@ public async Task ApplyAsync_PreservesExistingMcpConfigContent() } [Fact] - public async Task ScanAsync_WhenAspireAlreadyConfigured_ReturnsNoApplicator() + public async Task ScanAsync_WhenAspireAlreadyConfigured_ReturnsPlaywrightCliApplicatorOnly() { using var workspace = TemporaryWorkspace.Create(outputHelper); var copilotFolder = workspace.CreateDirectory(".copilot"); @@ -141,10 +144,6 @@ public async Task ScanAsync_WhenAspireAlreadyConfigured_ReturnsNoApplicator() ["aspire"] = new JsonObject { ["command"] = "aspire" - }, - ["playwright"] = new JsonObject - { - ["command"] = "npx" } } }; @@ -158,13 +157,14 @@ public async Task ScanAsync_WhenAspireAlreadyConfigured_ReturnsNoApplicator() var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // No applicators should be returned since Aspire MCP, Playwright MCP are configured and skill file exists with same content - Assert.Empty(context.Applicators); + // Only the Playwright CLI applicator should be offered (Aspire MCP is configured, skill file is up to date) + Assert.Single(context.Applicators); + Assert.Contains(context.Applicators, a => a.Description.Contains("Playwright CLI")); } [Fact] @@ -173,12 +173,12 @@ public async Task ScanAsync_WhenInVSCode_ReturnsApplicatorWithoutCallingRunner() using var workspace = TemporaryWorkspace.Create(outputHelper); var copilotCliRunner = new FakeCopilotCliRunner(null); // Return null to verify it's not called var executionContext = CreateExecutionContextWithVSCode(workspace.WorkspaceRoot); - var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions + // Scanner adds applicators for: Aspire MCP, Playwright CLI, and agent instructions Assert.NotEmpty(context.Applicators); Assert.Contains(context.Applicators, a => a.Description.Contains("GitHub Copilot")); Assert.False(copilotCliRunner.WasCalled); // Verify GetVersionAsync was not called @@ -239,7 +239,7 @@ public async Task ApplyAsync_WithMalformedMcpJson_ThrowsInvalidOperationExceptio var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -267,7 +267,7 @@ public async Task ApplyAsync_WithEmptyMcpJson_ThrowsInvalidOperationException() var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -293,7 +293,7 @@ public async Task ApplyAsync_WithMalformedMcpJson_DoesNotOverwriteFile() var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -322,4 +322,15 @@ private sealed class FakeCopilotCliRunner(SemVersion? version) : ICopilotCliRunn return Task.FromResult(version); } } + + private static PlaywrightCliInstaller CreatePlaywrightCliInstaller() + { + return new PlaywrightCliInstaller( + new FakeNpmRunner(), + new FakeNpmProvenanceChecker(), + new FakePlaywrightCliRunner(), + new TestConsoleInteractionService(), + new ConfigurationBuilder().Build(), + NullLogger.Instance); + } } diff --git a/tests/Aspire.Cli.Tests/Agents/NpmProvenanceCheckerTests.cs b/tests/Aspire.Cli.Tests/Agents/NpmProvenanceCheckerTests.cs new file mode 100644 index 00000000000..a6368649911 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Agents/NpmProvenanceCheckerTests.cs @@ -0,0 +1,308 @@ +// 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 System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Cli.Npm; + +namespace Aspire.Cli.Tests.Agents; + +public class NpmProvenanceCheckerTests +{ + [Fact] + public void ParseProvenance_WithValidSlsaProvenance_ReturnsVerifiedWithData() + { + var json = BuildAttestationJson("https://github.com/microsoft/playwright-cli"); + + var result = NpmProvenanceChecker.ParseProvenance(json); + + Assert.NotNull(result); + Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Value.Outcome); + Assert.Equal("https://github.com/microsoft/playwright-cli", result.Value.Provenance.SourceRepository); + Assert.Equal(".github/workflows/publish.yml", result.Value.Provenance.WorkflowPath); + Assert.Equal("https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", result.Value.Provenance.BuildType); + Assert.Equal("https://github.com/actions/runner/github-hosted", result.Value.Provenance.BuilderId); + Assert.Equal("refs/tags/v0.1.1", result.Value.Provenance.WorkflowRef); + } + + [Fact] + public void ParseProvenance_WithDifferentRepository_ReturnsVerifiedWithThatRepository() + { + var json = BuildAttestationJson("https://github.com/attacker/malicious-package"); + + var result = NpmProvenanceChecker.ParseProvenance(json); + + Assert.NotNull(result); + Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Value.Outcome); + Assert.Equal("https://github.com/attacker/malicious-package", result.Value.Provenance.SourceRepository); + } + + [Fact] + public void ParseProvenance_WithNoSlsaPredicate_ReturnsSlsaProvenanceNotFound() + { + var json = """ + { + "attestations": [ + { + "predicateType": "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", + "bundle": { + "dsseEnvelope": { + "payload": "" + } + } + } + ] + } + """; + + var result = NpmProvenanceChecker.ParseProvenance(json); + + Assert.NotNull(result); + Assert.Equal(ProvenanceVerificationOutcome.SlsaProvenanceNotFound, result.Value.Outcome); + } + + [Fact] + public void ParseProvenance_WithEmptyAttestations_ReturnsSlsaProvenanceNotFound() + { + var json = """{"attestations": []}"""; + + var result = NpmProvenanceChecker.ParseProvenance(json); + + Assert.NotNull(result); + Assert.Equal(ProvenanceVerificationOutcome.SlsaProvenanceNotFound, result.Value.Outcome); + } + + [Fact] + public void ParseProvenance_WithMalformedJson_ThrowsException() + { + Assert.ThrowsAny(() => NpmProvenanceChecker.ParseProvenance("not json")); + } + + [Fact] + public void ParseProvenance_WithMissingWorkflowNode_ReturnsSourceRepositoryNotFound() + { + var statement = new JsonObject + { + ["_type"] = "https://in-toto.io/Statement/v1", + ["predicateType"] = "https://slsa.dev/provenance/v1", + ["predicate"] = new JsonObject + { + ["buildDefinition"] = new JsonObject + { + ["externalParameters"] = new JsonObject() + } + } + }; + + var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(statement.ToJsonString())); + var json = $$""" + { + "attestations": [ + { + "predicateType": "https://slsa.dev/provenance/v1", + "bundle": { + "dsseEnvelope": { + "payload": "{{payload}}" + } + } + } + ] + } + """; + + var result = NpmProvenanceChecker.ParseProvenance(json); + + Assert.NotNull(result); + Assert.Equal(ProvenanceVerificationOutcome.SourceRepositoryNotFound, result.Value.Outcome); + } + + [Fact] + public void ParseProvenance_WithMissingPayload_ReturnsPayloadDecodeFailed() + { + var json = """ + { + "attestations": [ + { + "predicateType": "https://slsa.dev/provenance/v1", + "bundle": { + "dsseEnvelope": {} + } + } + ] + } + """; + + var result = NpmProvenanceChecker.ParseProvenance(json); + + Assert.NotNull(result); + Assert.Equal(ProvenanceVerificationOutcome.PayloadDecodeFailed, result.Value.Outcome); + } + + [Fact] + public async Task VerifyProvenanceAsync_WithMismatchedWorkflowRef_ReturnsWorkflowRefMismatch() + { + var json = BuildAttestationJson( + "https://github.com/microsoft/playwright-cli", + workflowRef: "refs/tags/v9.9.9"); + + var handler = new TestHttpMessageHandler(json); + var httpClient = new HttpClient(handler); + var checker = new NpmProvenanceChecker(httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + var result = await checker.VerifyProvenanceAsync( + "@playwright/cli", + "0.1.1", + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && + string.Equals(refInfo.Name, "v0.1.1", StringComparison.Ordinal), + CancellationToken.None); + + Assert.Equal(ProvenanceVerificationOutcome.WorkflowRefMismatch, result.Outcome); + Assert.Equal("refs/tags/v9.9.9", result.Provenance?.WorkflowRef); + } + + [Fact] + public async Task VerifyProvenanceAsync_WithMatchingWorkflowRef_ReturnsVerified() + { + var json = BuildAttestationJson( + "https://github.com/microsoft/playwright-cli", + workflowRef: "refs/tags/v0.1.1"); + + var handler = new TestHttpMessageHandler(json); + var httpClient = new HttpClient(handler); + var checker = new NpmProvenanceChecker(httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + var result = await checker.VerifyProvenanceAsync( + "@playwright/cli", + "0.1.1", + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && + string.Equals(refInfo.Name, "v0.1.1", StringComparison.Ordinal), + CancellationToken.None); + + Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); + } + + [Fact] + public async Task VerifyProvenanceAsync_WithNullCallback_SkipsRefValidation() + { + var json = BuildAttestationJson( + "https://github.com/microsoft/playwright-cli", + workflowRef: "refs/tags/any-format-at-all"); + + var handler = new TestHttpMessageHandler(json); + var httpClient = new HttpClient(handler); + var checker = new NpmProvenanceChecker(httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + var result = await checker.VerifyProvenanceAsync( + "@playwright/cli", + "0.1.1", + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + validateWorkflowRef: null, + CancellationToken.None); + + Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); + } + + private static string BuildAttestationJson(string sourceRepository, string workflowPath = ".github/workflows/publish.yml", string buildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", string workflowRef = "refs/tags/v0.1.1") + { + var statement = new JsonObject + { + ["_type"] = "https://in-toto.io/Statement/v1", + ["predicateType"] = "https://slsa.dev/provenance/v1", + ["predicate"] = new JsonObject + { + ["buildDefinition"] = new JsonObject + { + ["buildType"] = buildType, + ["externalParameters"] = new JsonObject + { + ["workflow"] = new JsonObject + { + ["repository"] = sourceRepository, + ["path"] = workflowPath, + ["ref"] = workflowRef + } + } + }, + ["runDetails"] = new JsonObject + { + ["builder"] = new JsonObject + { + ["id"] = "https://github.com/actions/runner/github-hosted" + } + } + } + }; + + var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(statement.ToJsonString())); + + var attestationResponse = new JsonObject + { + ["attestations"] = new JsonArray + { + new JsonObject + { + ["predicateType"] = "https://slsa.dev/provenance/v1", + ["bundle"] = new JsonObject + { + ["dsseEnvelope"] = new JsonObject + { + ["payload"] = payload + } + } + } + } + }; + + return attestationResponse.ToJsonString(); + } + + private sealed class TestHttpMessageHandler(string responseContent) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }); + } + } + + [Theory] + [InlineData("refs/tags/v0.1.1", "tags", "v0.1.1")] + [InlineData("refs/heads/main", "heads", "main")] + [InlineData("refs/tags/@scope/pkg@1.0.0", "tags", "@scope/pkg@1.0.0")] + [InlineData("refs/tags/release/1.0.0", "tags", "release/1.0.0")] + public void WorkflowRefInfo_TryParse_ValidRefs_ParsesCorrectly(string raw, string expectedKind, string expectedName) + { + var success = WorkflowRefInfo.TryParse(raw, out var refInfo); + + Assert.True(success); + Assert.NotNull(refInfo); + Assert.Equal(raw, refInfo.Raw); + Assert.Equal(expectedKind, refInfo.Kind); + Assert.Equal(expectedName, refInfo.Name); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-ref")] + [InlineData("refs/")] + [InlineData("refs/tags/")] + public void WorkflowRefInfo_TryParse_InvalidRefs_ReturnsFalse(string? raw) + { + var success = WorkflowRefInfo.TryParse(raw, out var refInfo); + + Assert.False(success); + Assert.Null(refInfo); + } +} diff --git a/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs index 0e8b4540592..6ec5bcf55e6 100644 --- a/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs @@ -2,8 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; using Aspire.Cli.Agents; using Aspire.Cli.Agents.OpenCode; +using Aspire.Cli.Agents.Playwright; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Logging.Abstractions; using Semver; @@ -22,7 +25,7 @@ public async Task ApplyAsync_WithMalformedOpenCodeJsonc_ThrowsInvalidOperationEx await File.WriteAllTextAsync(configPath, "{ invalid json content"); var openCodeCliRunner = new FakeOpenCodeCliRunner(new SemVersion(1, 0, 0)); - var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, NullLogger.Instance); + var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, CreatePlaywrightCliInstaller(), NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -48,7 +51,7 @@ public async Task ApplyAsync_WithEmptyOpenCodeJsonc_ThrowsInvalidOperationExcept await File.WriteAllTextAsync(configPath, ""); var openCodeCliRunner = new FakeOpenCodeCliRunner(new SemVersion(1, 0, 0)); - var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, NullLogger.Instance); + var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, CreatePlaywrightCliInstaller(), NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -72,7 +75,7 @@ public async Task ApplyAsync_WithMalformedOpenCodeJsonc_DoesNotOverwriteFile() await File.WriteAllTextAsync(configPath, originalContent); var openCodeCliRunner = new FakeOpenCodeCliRunner(new SemVersion(1, 0, 0)); - var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, NullLogger.Instance); + var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, CreatePlaywrightCliInstaller(), NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -98,6 +101,17 @@ private static AgentEnvironmentScanContext CreateScanContext( }; } + private static PlaywrightCliInstaller CreatePlaywrightCliInstaller() + { + return new PlaywrightCliInstaller( + new FakeNpmRunner(), + new FakeNpmProvenanceChecker(), + new FakePlaywrightCliRunner(), + new TestConsoleInteractionService(), + new ConfigurationBuilder().Build(), + NullLogger.Instance); + } + private sealed class FakeOpenCodeCliRunner(SemVersion? version) : IOpenCodeCliRunner { public Task GetVersionAsync(CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs new file mode 100644 index 00000000000..ee4f1f9f619 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs @@ -0,0 +1,567 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using Aspire.Cli.Agents; +using Aspire.Cli.Agents.Playwright; +using Aspire.Cli.Npm; +using Aspire.Cli.Tests.TestServices; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Semver; + +namespace Aspire.Cli.Tests.Agents; + +public class PlaywrightCliInstallerTests +{ + private static AgentEnvironmentScanContext CreateTestContext() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + return new AgentEnvironmentScanContext + { + WorkingDirectory = new DirectoryInfo(tempDir), + RepositoryRoot = new DirectoryInfo(tempDir) + }; + } + + [Fact] + public async Task InstallAsync_WhenNpmResolveReturnsNull_ReturnsFalse() + { + var npmRunner = new TestNpmRunner + { + ResolveResult = null + }; + var playwrightRunner = new TestPlaywrightCliRunner(); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.False(result); + } + + [Fact] + public async Task InstallAsync_WhenAlreadyInstalledAtSameVersion_SkipsInstallAndInstallsSkills() + { + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" } + }; + var playwrightRunner = new TestPlaywrightCliRunner + { + InstalledVersion = version, + InstallSkillsResult = true + }; + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.True(result); + Assert.True(playwrightRunner.InstallSkillsCalled); + Assert.False(npmRunner.PackCalled); + Assert.False(npmRunner.InstallGlobalCalled); + } + + [Fact] + public async Task InstallAsync_WhenNewerVersionInstalled_SkipsInstallAndInstallsSkills() + { + var targetVersion = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var installedVersion = SemVersion.Parse("0.2.0", SemVersionStyles.Strict); + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = targetVersion, Integrity = "sha512-abc123" } + }; + var playwrightRunner = new TestPlaywrightCliRunner + { + InstalledVersion = installedVersion, + InstallSkillsResult = true + }; + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.True(result); + Assert.True(playwrightRunner.InstallSkillsCalled); + Assert.False(npmRunner.PackCalled); + } + + [Fact] + public async Task InstallAsync_WhenPackFails_ReturnsFalse() + { + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" }, + PackResult = null + }; + var playwrightRunner = new TestPlaywrightCliRunner(); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.False(result); + Assert.True(npmRunner.PackCalled); + } + + [Fact] + public async Task InstallAsync_WhenIntegrityCheckFails_ReturnsFalse() + { + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + // Create a temp file with known content and a non-matching hash + var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tarballPath = Path.Combine(tempDir, "package.tgz"); + await File.WriteAllBytesAsync(tarballPath, [1, 2, 3]); + + try + { + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-definitelyWrongHash" }, + PackResult = tarballPath + }; + var playwrightRunner = new TestPlaywrightCliRunner(); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.False(result); + Assert.False(npmRunner.InstallGlobalCalled); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task InstallAsync_WhenIntegrityCheckPasses_InstallsGlobally() + { + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tarballPath = Path.Combine(tempDir, "package.tgz"); + var content = new byte[] { 10, 20, 30, 40, 50 }; + await File.WriteAllBytesAsync(tarballPath, content); + + // Compute the correct SRI hash for the content + var hash = SHA512.HashData(content); + var integrity = $"sha512-{Convert.ToBase64String(hash)}"; + + try + { + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = integrity }, + PackResult = tarballPath, + InstallGlobalResult = true + }; + var playwrightRunner = new TestPlaywrightCliRunner + { + InstallSkillsResult = true + }; + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.True(result); + Assert.True(npmRunner.InstallGlobalCalled); + Assert.True(playwrightRunner.InstallSkillsCalled); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task InstallAsync_WhenGlobalInstallFails_ReturnsFalse() + { + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tarballPath = Path.Combine(tempDir, "package.tgz"); + var content = new byte[] { 10, 20, 30 }; + await File.WriteAllBytesAsync(tarballPath, content); + + var hash = SHA512.HashData(content); + var integrity = $"sha512-{Convert.ToBase64String(hash)}"; + + try + { + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = integrity }, + PackResult = tarballPath, + InstallGlobalResult = false + }; + var playwrightRunner = new TestPlaywrightCliRunner(); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.False(result); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task InstallAsync_WhenOlderVersionInstalled_PerformsUpgrade() + { + var targetVersion = SemVersion.Parse("0.1.2", SemVersionStyles.Strict); + var installedVersion = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tarballPath = Path.Combine(tempDir, "package.tgz"); + var content = new byte[] { 99, 100 }; + await File.WriteAllBytesAsync(tarballPath, content); + + var hash = SHA512.HashData(content); + var integrity = $"sha512-{Convert.ToBase64String(hash)}"; + + try + { + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = targetVersion, Integrity = integrity }, + PackResult = tarballPath, + InstallGlobalResult = true + }; + var playwrightRunner = new TestPlaywrightCliRunner + { + InstalledVersion = installedVersion, + InstallSkillsResult = true + }; + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.True(result); + Assert.True(npmRunner.PackCalled); + Assert.True(npmRunner.InstallGlobalCalled); + Assert.True(playwrightRunner.InstallSkillsCalled); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void VerifyIntegrity_WithMatchingHash_ReturnsTrue() + { + var tempPath = Path.GetTempFileName(); + try + { + var content = "test content for hashing"u8.ToArray(); + File.WriteAllBytes(tempPath, content); + + var hash = SHA512.HashData(content); + var integrity = $"sha512-{Convert.ToBase64String(hash)}"; + + Assert.True(PlaywrightCliInstaller.VerifyIntegrity(tempPath, integrity)); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public void VerifyIntegrity_WithNonMatchingHash_ReturnsFalse() + { + var tempPath = Path.GetTempFileName(); + try + { + File.WriteAllText(tempPath, "some content"); + + Assert.False(PlaywrightCliInstaller.VerifyIntegrity(tempPath, "sha512-wronghash")); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public void VerifyIntegrity_WithNonSha512Prefix_ReturnsFalse() + { + var tempPath = Path.GetTempFileName(); + try + { + File.WriteAllText(tempPath, "some content"); + + Assert.False(PlaywrightCliInstaller.VerifyIntegrity(tempPath, "sha256-somehash")); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public async Task InstallAsync_WhenProvenanceCheckFails_ReturnsFalse() + { + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" } + }; + var provenanceChecker = new TestNpmProvenanceChecker { ProvenanceOutcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch }; + var playwrightRunner = new TestPlaywrightCliRunner(); + var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.False(result); + Assert.True(provenanceChecker.ProvenanceCalled); + Assert.False(npmRunner.PackCalled); + } + + [Fact] + public async Task InstallAsync_WhenValidationDisabled_SkipsAllValidationChecks() + { + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tarballPath = Path.Combine(tempDir, "package.tgz"); + await File.WriteAllBytesAsync(tarballPath, [10, 20, 30]); + + // Use a mismatched integrity hash — validation is disabled so it should still succeed. + try + { + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-wronghash" }, + PackResult = tarballPath + }; + var provenanceChecker = new TestNpmProvenanceChecker { ProvenanceOutcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; + var playwrightRunner = new TestPlaywrightCliRunner { InstallSkillsResult = true }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PlaywrightCliInstaller.DisablePackageValidationKey] = "true" + }) + .Build(); + var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestConsoleInteractionService(), configuration, NullLogger.Instance); + + var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.True(result); + Assert.False(provenanceChecker.ProvenanceCalled); + Assert.True(npmRunner.PackCalled); + Assert.True(npmRunner.InstallGlobalCalled); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task InstallAsync_WhenVersionOverrideConfigured_UsesOverrideVersion() + { + var version = SemVersion.Parse("0.2.0", SemVersionStyles.Strict); + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" } + }; + var playwrightRunner = new TestPlaywrightCliRunner(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PlaywrightCliInstaller.VersionOverrideKey] = "0.2.0" + }) + .Build(); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), configuration, NullLogger.Instance); + + await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.Equal("0.2.0", npmRunner.ResolvedVersionRange); + } + + [Fact] + public async Task InstallAsync_WhenNoVersionOverride_UsesDefaultRange() + { + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" } + }; + var playwrightRunner = new TestPlaywrightCliRunner(); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.Equal(PlaywrightCliInstaller.VersionRange, npmRunner.ResolvedVersionRange); + } + + [Fact] + public async Task InstallAsync_MirrorsSkillFilesToOtherAgentEnvironments() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-mirror-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + // Set up the primary skill directory with a skill file (simulating playwright-cli output) + var primarySkillDir = Path.Combine(tempDir, ".claude", "skills", "playwright-cli"); + Directory.CreateDirectory(primarySkillDir); + await File.WriteAllTextAsync(Path.Combine(primarySkillDir, "SKILL.md"), "# Playwright CLI Skill"); + Directory.CreateDirectory(Path.Combine(primarySkillDir, "subdir")); + await File.WriteAllTextAsync(Path.Combine(primarySkillDir, "subdir", "extra.md"), "Extra content"); + + var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict); + var playwrightRunner = new TestPlaywrightCliRunner + { + InstalledVersion = version, + InstallSkillsResult = true + }; + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" } + }; + + var installer = new PlaywrightCliInstaller( + npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, + new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), + NullLogger.Instance); + + var context = new AgentEnvironmentScanContext + { + WorkingDirectory = new DirectoryInfo(tempDir), + RepositoryRoot = new DirectoryInfo(tempDir) + }; + context.AddSkillBaseDirectory(Path.Combine(".claude", "skills")); + context.AddSkillBaseDirectory(Path.Combine(".github", "skills")); + context.AddSkillBaseDirectory(Path.Combine(".opencode", "skill")); + + await installer.InstallAsync(context, CancellationToken.None); + + // Verify files were mirrored to .github/skills/playwright-cli/ + Assert.True(File.Exists(Path.Combine(tempDir, ".github", "skills", "playwright-cli", "SKILL.md"))); + Assert.True(File.Exists(Path.Combine(tempDir, ".github", "skills", "playwright-cli", "subdir", "extra.md"))); + Assert.Equal("# Playwright CLI Skill", await File.ReadAllTextAsync(Path.Combine(tempDir, ".github", "skills", "playwright-cli", "SKILL.md"))); + + // Verify files were mirrored to .opencode/skill/playwright-cli/ + Assert.True(File.Exists(Path.Combine(tempDir, ".opencode", "skill", "playwright-cli", "SKILL.md"))); + Assert.True(File.Exists(Path.Combine(tempDir, ".opencode", "skill", "playwright-cli", "subdir", "extra.md"))); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void SyncDirectory_RemovesExtraFilesInTarget() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-sync-test-{Guid.NewGuid():N}"); + var sourceDir = Path.Combine(tempDir, "source"); + var targetDir = Path.Combine(tempDir, "target"); + + try + { + // Set up source with one file + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Combine(sourceDir, "keep.md"), "keep"); + + // Set up target with an extra file that should be removed + Directory.CreateDirectory(targetDir); + File.WriteAllText(Path.Combine(targetDir, "keep.md"), "old content"); + File.WriteAllText(Path.Combine(targetDir, "stale.md"), "should be removed"); + Directory.CreateDirectory(Path.Combine(targetDir, "stale-dir")); + File.WriteAllText(Path.Combine(targetDir, "stale-dir", "old.md"), "should be removed"); + + PlaywrightCliInstaller.SyncDirectory(sourceDir, targetDir); + + // Source file should be copied + Assert.Equal("keep", File.ReadAllText(Path.Combine(targetDir, "keep.md"))); + + // Stale files and directories should be removed + Assert.False(File.Exists(Path.Combine(targetDir, "stale.md"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "stale-dir"))); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + private sealed class TestNpmRunner : INpmRunner + { + public NpmPackageInfo? ResolveResult { get; set; } + public string? PackResult { get; set; } + public bool AuditResult { get; set; } = true; + public bool InstallGlobalResult { get; set; } = true; + + public bool PackCalled { get; private set; } + public bool InstallGlobalCalled { get; private set; } + public string? ResolvedVersionRange { get; private set; } + + public Task ResolvePackageAsync(string packageName, string versionRange, CancellationToken cancellationToken) + { + ResolvedVersionRange = versionRange; + return Task.FromResult(ResolveResult); + } + + public Task PackAsync(string packageName, string version, string outputDirectory, CancellationToken cancellationToken) + { + PackCalled = true; + return Task.FromResult(PackResult); + } + + public Task AuditSignaturesAsync(string packageName, string version, CancellationToken cancellationToken) + => Task.FromResult(AuditResult); + + public Task InstallGlobalAsync(string tarballPath, CancellationToken cancellationToken) + { + InstallGlobalCalled = true; + return Task.FromResult(InstallGlobalResult); + } + } + + private sealed class TestNpmProvenanceChecker : INpmProvenanceChecker + { + public ProvenanceVerificationOutcome ProvenanceOutcome { get; set; } = ProvenanceVerificationOutcome.Verified; + public bool ProvenanceCalled { get; private set; } + + public Task VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null) + { + ProvenanceCalled = true; + return Task.FromResult(new ProvenanceVerificationResult + { + Outcome = ProvenanceOutcome, + Provenance = ProvenanceOutcome is ProvenanceVerificationOutcome.Verified + ? new NpmProvenanceData { SourceRepository = expectedSourceRepository } + : new NpmProvenanceData() + }); + } + } + + private sealed class TestPlaywrightCliRunner : IPlaywrightCliRunner + { + public SemVersion? InstalledVersion { get; set; } + public bool InstallSkillsResult { get; set; } + public bool InstallSkillsCalled { get; private set; } + + public Task GetVersionAsync(CancellationToken cancellationToken) + => Task.FromResult(InstalledVersion); + + public Task InstallSkillsAsync(CancellationToken cancellationToken) + { + InstallSkillsCalled = true; + return Task.FromResult(InstallSkillsResult); + } + } +} diff --git a/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs b/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs new file mode 100644 index 00000000000..c0a6541e9fb --- /dev/null +++ b/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs @@ -0,0 +1,325 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Npm; + +namespace Aspire.Cli.Tests.Agents; + +public class SigstoreNpmProvenanceCheckerTests +{ + [Fact] + public void ParseAttestation_WithValidSlsaAttestation_ReturnsBundleAndProvenance() + { + var json = BuildAttestationJsonWithBundle("https://github.com/microsoft/playwright-cli"); + + var result = SigstoreNpmProvenanceChecker.ParseAttestation(json); + + Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); + Assert.NotNull(result.BundleNode); + Assert.NotNull(result.BundleNode["dsseEnvelope"]); + Assert.NotNull(result.Provenance); + Assert.Equal("https://github.com/microsoft/playwright-cli", result.Provenance.SourceRepository); + Assert.Equal(".github/workflows/publish.yml", result.Provenance.WorkflowPath); + Assert.Equal("refs/tags/v0.1.1", result.Provenance.WorkflowRef); + } + + [Fact] + public void ParseAttestation_WithNoSlsaPredicate_ReturnsSlsaProvenanceNotFound() + { + var json = """ + { + "attestations": [ + { + "predicateType": "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", + "bundle": { + "dsseEnvelope": { + "payload": "" + } + } + } + ] + } + """; + + var result = SigstoreNpmProvenanceChecker.ParseAttestation(json); + + Assert.Equal(ProvenanceVerificationOutcome.SlsaProvenanceNotFound, result.Outcome); + } + + [Fact] + public void ParseAttestation_WithEmptyAttestations_ReturnsSlsaProvenanceNotFound() + { + var json = """{"attestations": []}"""; + + var result = SigstoreNpmProvenanceChecker.ParseAttestation(json); + + Assert.Equal(ProvenanceVerificationOutcome.SlsaProvenanceNotFound, result.Outcome); + } + + [Fact] + public void ParseAttestation_WithInvalidJson_ReturnsAttestationParseFailed() + { + var result = SigstoreNpmProvenanceChecker.ParseAttestation("not valid json {{{"); + + Assert.Equal(ProvenanceVerificationOutcome.AttestationParseFailed, result.Outcome); + } + + [Fact] + public void ParseAttestation_WithMissingPayload_ReturnsPayloadDecodeFailed() + { + var json = """ + { + "attestations": [ + { + "predicateType": "https://slsa.dev/provenance/v1", + "bundle": { + "dsseEnvelope": {} + } + } + ] + } + """; + + var result = SigstoreNpmProvenanceChecker.ParseAttestation(json); + + Assert.Equal(ProvenanceVerificationOutcome.PayloadDecodeFailed, result.Outcome); + Assert.NotNull(result.BundleNode); + } + + [Fact] + public void ParseProvenanceFromStatement_WithValidStatement_ReturnsProvenance() + { + var payload = BuildProvenancePayload("https://github.com/microsoft/playwright-cli"); + var bytes = System.Text.Encoding.UTF8.GetBytes(payload); + + var provenance = SigstoreNpmProvenanceChecker.ParseProvenanceFromStatement(bytes); + + Assert.NotNull(provenance); + Assert.Equal("https://github.com/microsoft/playwright-cli", provenance.SourceRepository); + Assert.Equal(".github/workflows/publish.yml", provenance.WorkflowPath); + Assert.Equal("refs/tags/v0.1.1", provenance.WorkflowRef); + Assert.Equal("https://github.com/actions/runner/github-hosted", provenance.BuilderId); + Assert.Equal("https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", provenance.BuildType); + } + + [Fact] + public void ParseProvenanceFromStatement_WithInvalidJson_ReturnsNull() + { + var bytes = System.Text.Encoding.UTF8.GetBytes("not json"); + + var provenance = SigstoreNpmProvenanceChecker.ParseProvenanceFromStatement(bytes); + + Assert.Null(provenance); + } + + [Fact] + public void VerifyProvenanceFields_WithAllFieldsMatching_ReturnsVerified() + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + WorkflowRef = "refs/tags/v0.1.1", + BuilderId = "https://github.com/actions/runner/github-hosted" + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + refInfo => refInfo.Kind == "tags"); + + Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); + } + + [Fact] + public void VerifyProvenanceFields_WithSourceRepoMismatch_ReturnsSourceRepositoryMismatch() + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/evil/repo", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + null); + + Assert.Equal(ProvenanceVerificationOutcome.SourceRepositoryMismatch, result.Outcome); + } + + [Fact] + public void VerifyProvenanceFields_WithWorkflowMismatch_ReturnsWorkflowMismatch() + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = ".github/workflows/evil.yml", + BuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + null); + + Assert.Equal(ProvenanceVerificationOutcome.WorkflowMismatch, result.Outcome); + } + + [Fact] + public void VerifyProvenanceFields_WithBuildTypeMismatch_ReturnsBuildTypeMismatch() + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = "https://evil.example.com/build/v1", + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + null); + + Assert.Equal(ProvenanceVerificationOutcome.BuildTypeMismatch, result.Outcome); + } + + [Fact] + public void VerifyProvenanceFields_WithWorkflowRefValidationFailure_ReturnsWorkflowRefMismatch() + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + WorkflowRef = "refs/heads/main" + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + refInfo => refInfo.Kind == "tags"); + + Assert.Equal(ProvenanceVerificationOutcome.WorkflowRefMismatch, result.Outcome); + } + + [Theory] + [InlineData("https://github.com/microsoft/playwright-cli", "microsoft", "playwright-cli")] + [InlineData("https://github.com/dotnet/aspire", "dotnet", "aspire")] + [InlineData("https://github.com/owner/repo", "owner", "repo")] + public void TryParseGitHubOwnerRepo_WithValidUrl_ReturnsTrueAndParsesComponents(string url, string expectedOwner, string expectedRepo) + { + var result = SigstoreNpmProvenanceChecker.TryParseGitHubOwnerRepo(url, out var owner, out var repo); + + Assert.True(result); + Assert.Equal(expectedOwner, owner); + Assert.Equal(expectedRepo, repo); + } + + [Theory] + [InlineData("not-a-url")] + [InlineData("https://github.com/")] + [InlineData("https://github.com/only-owner")] + public void TryParseGitHubOwnerRepo_WithInvalidUrl_ReturnsFalse(string url) + { + var result = SigstoreNpmProvenanceChecker.TryParseGitHubOwnerRepo(url, out _, out _); + + Assert.False(result); + } + + private static string BuildAttestationJsonWithBundle(string sourceRepository) + { + var payload = BuildProvenancePayload(sourceRepository); + var payloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)); + + return $$""" + { + "attestations": [ + { + "predicateType": "https://slsa.dev/provenance/v1", + "bundle": { + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "dsseEnvelope": { + "payload": "{{payloadBase64}}", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEUCIQC+fake+signature", + "keyid": "" + } + ] + }, + "verificationMaterial": { + "certificate": { + "rawBytes": "MIIFake..." + }, + "tlogEntries": [ + { + "logIndex": "12345", + "logId": { + "keyId": "fake-key-id" + }, + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "integratedTime": "1700000000", + "inclusionPromise": { + "signedEntryTimestamp": "MEUC..." + }, + "canonicalizedBody": "eyJ..." + } + ] + } + } + } + ] + } + """; + } + + private static string BuildProvenancePayload(string sourceRepository) + { + return $$""" + { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "pkg:npm/@playwright/cli@0.1.1", + "digest": { "sha512": "abc123" } + } + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + "externalParameters": { + "workflow": { + "ref": "refs/tags/v0.1.1", + "repository": "{{sourceRepository}}", + "path": ".github/workflows/publish.yml" + } + } + }, + "runDetails": { + "builder": { + "id": "https://github.com/actions/runner/github-hosted" + } + } + } + } + """; + } +} diff --git a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs index 989170312cd..41dea5f7079 100644 --- a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; using System.Text.Json.Nodes; using Aspire.Cli.Agents; +using Aspire.Cli.Agents.Playwright; using Aspire.Cli.Agents.VsCode; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.Logging.Abstractions; using Semver; @@ -20,12 +23,12 @@ public async Task ScanAsync_WhenVsCodeFolderExists_ReturnsApplicator() var vsCodeFolder = workspace.CreateDirectory(".vscode"); var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions + // Scanner adds applicators for: Aspire MCP, Playwright CLI, and agent instructions Assert.NotEmpty(context.Applicators); Assert.Contains(context.Applicators, a => a.Description.Contains("VS Code")); } @@ -38,12 +41,12 @@ public async Task ScanAsync_WhenVsCodeFolderExistsInParent_ReturnsApplicatorForP var childDir = workspace.CreateDirectory("subdir"); var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(childDir); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(childDir, workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions + // Scanner adds applicators for: Aspire MCP, Playwright CLI, and agent instructions Assert.NotEmpty(context.Applicators); Assert.Contains(context.Applicators, a => a.Description.Contains("VS Code")); } @@ -56,7 +59,7 @@ public async Task ScanAsync_WhenRepositoryRootReachedBeforeVsCode_AndNoCliAvaila // Repository root is the workspace root, so search should stop there var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(childDir); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(childDir, workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -70,12 +73,12 @@ public async Task ScanAsync_WhenNoVsCodeFolder_AndVsCodeCliAvailable_ReturnsAppl using var workspace = TemporaryWorkspace.Create(outputHelper); var vsCodeCliRunner = new FakeVsCodeCliRunner(new SemVersion(1, 85, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions + // Scanner adds applicators for: Aspire MCP, Playwright CLI, and agent instructions Assert.NotEmpty(context.Applicators); Assert.Contains(context.Applicators, a => a.Description.Contains("VS Code")); } @@ -86,7 +89,7 @@ public async Task ScanAsync_WhenNoVsCodeFolder_AndNoCliAvailable_ReturnsNoApplic using var workspace = TemporaryWorkspace.Create(outputHelper); var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); // This test assumes no VSCODE_* environment variables are set @@ -104,7 +107,7 @@ public async Task ApplyAsync_CreatesVsCodeFolderIfNotExists() var vsCodePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".vscode"); var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); // First, make the scanner find a parent .vscode folder to get an applicator var parentVsCode = workspace.CreateDirectory(".vscode"); @@ -112,7 +115,7 @@ public async Task ApplyAsync_CreatesVsCodeFolderIfNotExists() await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions + // Scanner adds applicators for: Aspire MCP, Playwright CLI, and agent instructions Assert.NotEmpty(context.Applicators); var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); @@ -131,7 +134,7 @@ public async Task ApplyAsync_CreatesMcpJsonWithCorrectConfiguration() var vsCodeFolder = workspace.CreateDirectory(".vscode"); var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -187,7 +190,7 @@ public async Task ApplyAsync_PreservesExistingMcpJsonContent() var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -228,12 +231,12 @@ public async Task ApplyAsync_UpdatesExistingAspireServerConfig() var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // Should return applicators for Aspire MCP, Playwright MCP, and agent instructions + // Should return applicators for Aspire MCP, Playwright CLI, and agent instructions Assert.NotEmpty(context.Applicators); var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); @@ -253,39 +256,19 @@ public async Task ApplyAsync_UpdatesExistingAspireServerConfig() } [Fact] - public async Task ApplyAsync_WithConfigurePlaywrightTrue_AddsPlaywrightServer() + public async Task ScanAsync_AddsPlaywrightCliApplicator() { using var workspace = TemporaryWorkspace.Create(outputHelper); var vsCodeFolder = workspace.CreateDirectory(".vscode"); var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - - // Apply both MCP-related applicators (Aspire and Playwright) - var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); - var playwrightApplicator = context.Applicators.First(a => a.Description.Contains("Playwright MCP")); - await aspireApplicator.ApplyAsync(CancellationToken.None).DefaultTimeout(); - await playwrightApplicator.ApplyAsync(CancellationToken.None).DefaultTimeout(); - - var mcpJsonPath = Path.Combine(vsCodeFolder.FullName, "mcp.json"); - var content = await File.ReadAllTextAsync(mcpJsonPath); - var config = JsonNode.Parse(content)?.AsObject(); - Assert.NotNull(config); - var servers = config["servers"]?.AsObject(); - Assert.NotNull(servers); - - // Both aspire and playwright servers should exist - Assert.True(servers.ContainsKey("aspire")); - Assert.True(servers.ContainsKey("playwright")); - - var playwrightServer = servers["playwright"]?.AsObject(); - Assert.NotNull(playwrightServer); - Assert.Equal("stdio", playwrightServer["type"]?.GetValue()); - Assert.Equal("npx", playwrightServer["command"]?.GetValue()); + // Should have a Playwright CLI installation applicator + Assert.Contains(context.Applicators, a => a.Description.Contains("Playwright CLI")); } [Fact] @@ -300,7 +283,7 @@ public async Task ApplyAsync_WithMalformedMcpJson_ThrowsInvalidOperationExceptio var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -328,7 +311,7 @@ public async Task ApplyAsync_WithEmptyMcpJson_ThrowsInvalidOperationException() var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -354,7 +337,7 @@ public async Task ApplyAsync_WithMalformedMcpJson_DoesNotOverwriteFile() var vsCodeCliRunner = new FakeVsCodeCliRunner(null); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, CreatePlaywrightCliInstaller(), executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); @@ -378,6 +361,17 @@ private sealed class FakeVsCodeCliRunner(SemVersion? version) : IVsCodeCliRunner public Task GetVersionAsync(VsCodeRunOptions options, CancellationToken cancellationToken) => Task.FromResult(version); } + private static PlaywrightCliInstaller CreatePlaywrightCliInstaller() + { + return new PlaywrightCliInstaller( + new FakeNpmRunner(), + new FakeNpmProvenanceChecker(), + new FakePlaywrightCliRunner(), + new TestConsoleInteractionService(), + new ConfigurationBuilder().Build(), + NullLogger.Instance); + } + private static AgentEnvironmentScanContext CreateScanContext( DirectoryInfo workingDirectory, DirectoryInfo? repositoryRoot = null) diff --git a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj index 421fde25b5e..94c86d802ba 100644 --- a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj +++ b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj @@ -5,6 +5,7 @@ enable enable false + false false diff --git a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs new file mode 100644 index 00000000000..a9aaf74caf4 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Agents.Playwright; +using Aspire.Cli.Npm; +using Semver; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// A fake implementation of for testing. +/// +internal sealed class FakeNpmRunner : INpmRunner +{ + public Task ResolvePackageAsync(string packageName, string versionRange, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task PackAsync(string packageName, string version, string outputDirectory, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task AuditSignaturesAsync(string packageName, string version, CancellationToken cancellationToken) + => Task.FromResult(true); + + public Task InstallGlobalAsync(string tarballPath, CancellationToken cancellationToken) + => Task.FromResult(true); +} + +/// +/// A fake implementation of for testing. +/// +internal sealed class FakeNpmProvenanceChecker : INpmProvenanceChecker +{ + public Task VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null) + => Task.FromResult(new ProvenanceVerificationResult + { + Outcome = ProvenanceVerificationOutcome.Verified, + Provenance = new NpmProvenanceData { SourceRepository = expectedSourceRepository } + }); +} + +/// +/// A fake implementation of for testing. +/// +internal sealed class FakePlaywrightCliRunner : IPlaywrightCliRunner +{ + public Task GetVersionAsync(CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task InstallSkillsAsync(CancellationToken cancellationToken) + => Task.FromResult(true); +} From 5338db3ea3ce626a1dce6f8bffa7bb455d9dc3d3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 3 Mar 2026 23:39:39 +1100 Subject: [PATCH 18/18] Remove temporary nuget.org feed from NuGet.config (#14877) NOTE: Merging to unblock build. * Remove temporary nuget.org feed from NuGet.config Remove the temporary nuget-org package source and its package source mapping entries (Sigstore, Tuf, NSec.Cryptography, libsodium). These packages will be resolved from the normal internal feeds (dotnet-public, dotnet-eng) which have wildcard pattern mappings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Sigstore and Tuf packages to 0.3.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- NuGet.config | 9 --------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d66793059c3..56e080f9da7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -132,8 +132,8 @@ - - + + diff --git a/NuGet.config b/NuGet.config index 6ffc9600bba..2822bc5d52a 100644 --- a/NuGet.config +++ b/NuGet.config @@ -20,8 +20,6 @@ - - @@ -45,13 +43,6 @@ - - - - - - -