diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index 17378b5e840..d35934611a1 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -20,6 +20,13 @@ namespace Aspire.Cli.Backchannel; /// internal sealed class AppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackchannel { + private static readonly string[] s_clientCapabilities = + [ + AuxiliaryBackchannelCapabilities.V1, + AuxiliaryBackchannelCapabilities.V2, + AuxiliaryBackchannelCapabilities.V3 + ]; + private readonly ILogger _logger; private JsonRpc? _rpc; private bool _disposed; @@ -332,6 +339,22 @@ [new WaitForAppHostReadyRequest()], /// public async Task> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default) { + if (SupportsV2) + { + var response = await GetResourcesV2Async(new GetResourcesRequest + { + ClientCapabilities = s_clientCapabilities + }, cancellationToken).ConfigureAwait(false); + var snapshots = response.Resources.ToList(); + + if (!includeHidden) + { + snapshots = snapshots.Where(s => !ResourceSnapshotMapper.IsHiddenResource(s)).ToList(); + } + + return snapshots.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + var rpc = EnsureConnected(); _logger.LogDebug("Getting resource snapshots"); @@ -363,6 +386,24 @@ public async Task> GetResourceSnapshotsAsync(bool include /// public async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (SupportsV2) + { + await foreach (var snapshot in WatchResourcesV2Async(new WatchResourcesRequest + { + ClientCapabilities = s_clientCapabilities + }, cancellationToken).ConfigureAwait(false)) + { + if (!includeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot)) + { + continue; + } + + yield return snapshot; + } + + yield break; + } + var rpc = EnsureConnected(); _logger.LogDebug("Starting resource snapshots watch"); @@ -555,6 +596,8 @@ [new GetDashboardInfoRequest()], /// The resources response. public async Task GetResourcesV2Async(GetResourcesRequest? request = null, CancellationToken cancellationToken = default) { + request = AddClientCapabilities(request); + if (!SupportsV2) { // Fall back to v1 @@ -596,6 +639,8 @@ public async IAsyncEnumerable WatchResourcesV2Async( WatchResourcesRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + request = AddClientCapabilities(request); + if (!SupportsV2) { // Fall back to v1 @@ -641,6 +686,30 @@ public async IAsyncEnumerable WatchResourcesV2Async( } } + private static GetResourcesRequest AddClientCapabilities(GetResourcesRequest? request) + { + return request is null + ? new GetResourcesRequest { ClientCapabilities = s_clientCapabilities } + : new GetResourcesRequest + { + TraceContext = request.TraceContext, + Filter = request.Filter, + ClientCapabilities = s_clientCapabilities + }; + } + + private static WatchResourcesRequest AddClientCapabilities(WatchResourcesRequest? request) + { + return request is null + ? new WatchResourcesRequest { ClientCapabilities = s_clientCapabilities } + : new WatchResourcesRequest + { + TraceContext = request.TraceContext, + Filter = request.Filter, + ClientCapabilities = s_clientCapabilities + }; + } + /// /// Gets console logs using the v2 API. /// Falls back to v1 if not supported. diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index b3803a39940..ffbebad6853 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -307,8 +307,7 @@ private void UpdateResourceActionsMenu() IsCommandExecuting, showViewDetails: false, showConsoleLogsItem: true, - showUrls: true, - showStartCommand: false); + showUrls: true); } private async Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command) diff --git a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs index 525b628c7a6..b8412e42c29 100644 --- a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs @@ -79,8 +79,7 @@ public void AddMenuItems( Func isCommandExecuting, bool showViewDetails, bool showConsoleLogsItem, - bool showUrls, - bool showStartCommand = true) + bool showUrls) { if (showViewDetails) { @@ -166,7 +165,7 @@ await _aiContextProvider.LaunchAssistantSidebarAsync( AddTelemetryMenuItems(menuItems, resource, resourceByName); - AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting, showStartCommand); + AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting); if (showUrls) { @@ -283,10 +282,9 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM } } - private void AddCommandMenuItems(List menuItems, ResourceViewModel resource, EventCallback commandSelected, Func isCommandExecuting, bool showStartCommand) + private void AddCommandMenuItems(List menuItems, ResourceViewModel resource, EventCallback commandSelected, Func isCommandExecuting) { var menuCommands = resource.Commands - .Where(c => showStartCommand || !c.Name.Equals(CommandViewModel.StartCommand, StringComparisons.CommandName)) .Where(c => c.State != CommandViewModelState.Hidden) .ToList(); diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 40eebb25ced..3c771c3a348 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -118,7 +119,7 @@ public async Task GetDashboardInfoAsync(GetDashboardIn public async Task GetResourcesAsync(GetResourcesRequest? request = null, CancellationToken cancellationToken = default) { using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(GetResourcesAsync), streaming: false, request?.TraceContext); - var snapshots = await GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + var snapshots = await GetResourceSnapshotsAsync(SupportsJsonResourceProperties(request?.ClientCapabilities), cancellationToken).ConfigureAwait(false); // Apply filter if specified if (!string.IsNullOrEmpty(request?.Filter)) @@ -147,7 +148,7 @@ public async IAsyncEnumerable WatchResourcesAsync(WatchResourc try { - await foreach (var snapshot in WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var snapshot in WatchResourceSnapshotsAsync(SupportsJsonResourceProperties(request?.ClientCapabilities), cancellationToken).ConfigureAwait(false)) { // Apply filter if specified if (!string.IsNullOrEmpty(filter) && !snapshot.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) @@ -734,6 +735,11 @@ public async Task WaitForAppHostReadyAsync(WaitForA /// A cancellation token. /// A list of resource snapshots. public async Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default) + { + return await GetResourceSnapshotsAsync(resourcePropertiesAsJson: false, cancellationToken).ConfigureAwait(false); + } + + private async Task> GetResourceSnapshotsAsync(bool resourcePropertiesAsJson, CancellationToken cancellationToken) { var appModel = serviceProvider.GetRequiredService(); var notificationService = serviceProvider.GetRequiredService(); @@ -754,7 +760,7 @@ async Task AddResult(string resourceName) { if (notificationService.TryGetCurrentState(resourceName, out var resourceEvent)) { - var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false); + var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, resourcePropertiesAsJson, cancellationToken).ConfigureAwait(false); if (snapshot is not null) { results.Add(snapshot); @@ -769,6 +775,16 @@ async Task AddResult(string resourceName) /// A cancellation token. /// An async enumerable of resource snapshots as they change. public async IAsyncEnumerable WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var snapshot in WatchResourceSnapshotsAsync(resourcePropertiesAsJson: false, cancellationToken).ConfigureAwait(false)) + { + yield return snapshot; + } + } + + private async IAsyncEnumerable WatchResourceSnapshotsAsync( + bool resourcePropertiesAsJson, + [EnumeratorCancellation] CancellationToken cancellationToken) { var notificationService = serviceProvider.GetRequiredService(); @@ -776,7 +792,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu await foreach (var resourceEvent in resourceEvents.WithCancellation(cancellationToken).ConfigureAwait(false)) { - var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false); + var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, resourcePropertiesAsJson, cancellationToken).ConfigureAwait(false); if (snapshot is not null) { yield return snapshot; @@ -786,6 +802,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu private async Task CreateResourceSnapshotFromEventAsync( ResourceEvent resourceEvent, + bool resourcePropertiesAsJson, CancellationToken cancellationToken) { var resource = resourceEvent.Resource; @@ -881,7 +898,9 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu continue; } - properties[prop.Name] = ConvertPropertyValueToJsonNode(prop.Value); + properties[prop.Name] = resourcePropertiesAsJson + ? ConvertPropertyValueToJsonNode(prop.Value) + : ConvertPropertyValueToLegacyJsonNode(prop.Value); if (string.Equals(prop.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourcePropertyName)) { @@ -971,6 +990,43 @@ private static ResourceSnapshotCommandArgument CreateCommandArgument(Interaction }; } + private static JsonNode? ConvertPropertyValueToLegacyJsonNode(object? value) + { + var stringValue = ConvertPropertyValueToString(value); + + return stringValue is null ? null : JsonValue.Create(stringValue); + } + + private static string? ConvertPropertyValueToString(object? value) + { + return value switch + { + null => null, + JsonValue jsonValue when jsonValue.TryGetValue(out var stringValue) => stringValue, + JsonValue jsonValue when jsonValue.TryGetValue(out var boolValue) => boolValue.ToString(CultureInfo.InvariantCulture), + JsonValue jsonValue when jsonValue.TryGetValue(out var formattableValue) => formattableValue.ToString(null, CultureInfo.InvariantCulture), + JsonNode jsonNode => jsonNode.ToJsonString(), + string stringValue => stringValue, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + System.Collections.IEnumerable enumerable => ConvertEnumerablePropertyValueToString(enumerable), + _ => value.ToString() + }; + } + + private static string ConvertEnumerablePropertyValueToString(System.Collections.IEnumerable enumerable) + { + var values = new List(); + foreach (var value in enumerable) + { + if (ConvertPropertyValueToString(value) is { } stringValue) + { + values.Add(stringValue); + } + } + + return string.Join(',', values); + } + private static JsonArray ConvertEnumerablePropertyValueToJsonArray(System.Collections.IEnumerable enumerable) { var array = new JsonArray(); @@ -982,6 +1038,11 @@ private static JsonArray ConvertEnumerablePropertyValueToJsonArray(System.Collec return array; } + private static bool SupportsJsonResourceProperties(string[]? clientCapabilities) + { + return clientCapabilities?.Contains(AuxiliaryBackchannelCapabilities.V3, StringComparer.Ordinal) == true; + } + private static string[]? GetStringArrayPropertyValue(object? value) { return value switch diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index cc7b05372d7..e2e42e65e36 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -46,7 +46,8 @@ internal static class AuxiliaryBackchannelCapabilities public const string V2 = "aux.v2"; /// - /// Version 3 capabilities: Batched console log streaming and AppHost startup readiness wait. + /// Version 3 capabilities: Batched console log streaming, AppHost startup readiness wait, + /// and JSON-valued resource properties when requested by the client. /// public const string V3 = "aux.v3"; } @@ -240,11 +241,17 @@ internal sealed class GetResourcesRequest : BackchannelRequest /// public string? Filter { get; init; } + /// + /// Gets the auxiliary backchannel capabilities supported by the client. + /// + public string[] ClientCapabilities { get; init; } = []; + /// public override GetResourcesRequest WithTraceContext(BackchannelTraceContext traceContext) => new() { TraceContext = traceContext, - Filter = Filter + Filter = Filter, + ClientCapabilities = ClientCapabilities }; } @@ -269,11 +276,17 @@ internal sealed class WatchResourcesRequest : BackchannelRequest /// public string? Filter { get; init; } + /// + /// Gets the auxiliary backchannel capabilities supported by the client. + /// + public string[] ClientCapabilities { get; init; } = []; + /// public override WatchResourcesRequest WithTraceContext(BackchannelTraceContext traceContext) => new() { TraceContext = traceContext, - Filter = Filter + Filter = Filter, + ClientCapabilities = ClientCapabilities }; } diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostAuxiliaryBackchannelTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostAuxiliaryBackchannelTests.cs new file mode 100644 index 00000000000..c14b9382494 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostAuxiliaryBackchannelTests.cs @@ -0,0 +1,157 @@ +// 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.Net.Sockets; +using System.Runtime.CompilerServices; +using Aspire.Cli.Backchannel; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using StreamJsonRpc; + +namespace Aspire.Cli.Tests.Backchannel; + +public class AppHostAuxiliaryBackchannelTests +{ + [Fact] + public async Task GetResourceSnapshotsAsync_SendsClientCapabilitiesWithV3() + { + using var server = TestAppHostBackchannelServer.Start(); + using var backchannel = await server.ConnectAsync().DefaultTimeout(); + + var snapshots = await backchannel.GetResourceSnapshotsAsync(includeHidden: true).DefaultTimeout(); + + var snapshot = Assert.Single(snapshots); + Assert.Equal("api", snapshot.Name); + Assert.NotNull(server.Target.GetResourcesRequest); + Assert.Contains(AuxiliaryBackchannelCapabilities.V3, server.Target.GetResourcesRequest.ClientCapabilities); + } + + [Fact] + public async Task WatchResourceSnapshotsAsync_SendsClientCapabilitiesWithV3() + { + using var server = TestAppHostBackchannelServer.Start(); + using var backchannel = await server.ConnectAsync().DefaultTimeout(); + + using var watchCancellation = new CancellationTokenSource(); + await using var enumerator = backchannel.WatchResourceSnapshotsAsync(includeHidden: true, watchCancellation.Token).GetAsyncEnumerator(); + + Assert.True(await enumerator.MoveNextAsync().DefaultTimeout()); + await watchCancellation.CancelAsync(); + + var resource = enumerator.Current; + Assert.Equal("api", resource.Name); + Assert.NotNull(server.Target.WatchResourcesRequest); + Assert.Contains(AuxiliaryBackchannelCapabilities.V3, server.Target.WatchResourcesRequest.ClientCapabilities); + } + + private sealed class TestAppHostBackchannelServer : IDisposable + { + private readonly TcpListener _listener; + private readonly List _disposables = []; + + private TestAppHostBackchannelServer() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + Target = new TestAppHostRpcTarget(); + } + + public TestAppHostRpcTarget Target { get; } + + public static TestAppHostBackchannelServer Start() + { + var server = new TestAppHostBackchannelServer(); + server._listener.Start(); + + return server; + } + + public async Task ConnectAsync() + { + var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + var acceptTask = _listener.AcceptSocketAsync(); + await clientSocket.ConnectAsync((IPEndPoint)_listener.LocalEndpoint).DefaultTimeout(); + var serverSocket = await acceptTask.DefaultTimeout(); + var serverStream = new NetworkStream(serverSocket, ownsSocket: true); + var messageHandler = new HeaderDelimitedMessageHandler(serverStream, serverStream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()); + var rpc = new JsonRpc(messageHandler, Target); + rpc.StartListening(); + _disposables.Add(rpc); + _disposables.Add(messageHandler); + _disposables.Add(serverStream); + + return await AppHostAuxiliaryBackchannel.CreateFromSocketAsync("hash1", "socket.hash1", isInScope: true, NullLogger.Instance, clientSocket).DefaultTimeout(); + } + + public void Dispose() + { + foreach (var disposable in _disposables) + { + disposable.Dispose(); + } + + _listener.Stop(); + } + } + + private sealed class TestAppHostRpcTarget + { + private readonly int _processId = Environment.ProcessId; + private readonly string[] _capabilities = + [ + AuxiliaryBackchannelCapabilities.V1, + AuxiliaryBackchannelCapabilities.V2 + ]; + + public GetResourcesRequest? GetResourcesRequest { get; private set; } + + public WatchResourcesRequest? WatchResourcesRequest { get; private set; } + + public Task GetAppHostInformationAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + + return Task.FromResult(new AppHostInformation + { + AppHostPath = "/path/to/AppHost.csproj", + ProcessId = _processId + }); + } + + public Task GetCapabilitiesAsync(GetCapabilitiesRequest? request = null, CancellationToken cancellationToken = default) + { + _ = request; + _ = cancellationToken; + + return Task.FromResult(new GetCapabilitiesResponse + { + Capabilities = _capabilities + }); + } + + public Task GetResourcesAsync(GetResourcesRequest? request = null, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + GetResourcesRequest = request; + + return Task.FromResult(new GetResourcesResponse + { + Resources = [CreateResourceSnapshot()] + }); + } + + public async IAsyncEnumerable WatchResourcesAsync(WatchResourcesRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + WatchResourcesRequest = request; + yield return CreateResourceSnapshot(); + await Task.CompletedTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private static ResourceSnapshot CreateResourceSnapshot() => + new() + { + Name = "api", + ResourceType = "Project" + }; + } +} diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index a29d60230d4..836805d289c 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -252,7 +252,7 @@ public void AddMenuItems_WithoutFromSpecEnvVars_ExportEnvMenuItemNotShown() } [Fact] - public void AddMenuItems_ShowStartCommandFalse_FiltersStartCommand() + public void AddMenuItems_IncludesStartCommandLikeOtherVisibleCommands() { var startCommand = new CommandViewModel( CommandViewModel.StartCommand, @@ -289,12 +289,12 @@ public void AddMenuItems_ShowStartCommandFalse_FiltersStartCommand() (_, _) => false, showViewDetails: false, showConsoleLogsItem: false, - showUrls: false, - showStartCommand: false); + showUrls: false); Assert.Collection(menuItems, e => Assert.Equal("Localized:ExportJson", e.Text), e => Assert.True(e.IsDivider), + e => Assert.Equal("Start", e.Text), e => Assert.Equal("Stop", e.Text)); } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs index dbdff9179ac..22ba5a19d4a 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -331,6 +331,91 @@ await notificationService.PublishUpdateAsync(custom.Resource, s => s with await app.StopAsync().DefaultTimeout(); } + [Fact] + public async Task GetResourceSnapshotsAsync_MapsNonStringPropertiesAsStringsForLegacyCallers() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + var custom = builder.AddResource(new CustomResource("myresource")); + + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + var notificationService = app.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(custom.Resource, s => s with + { + Properties = + [ + new ResourcePropertySnapshot("number", 42), + new ResourcePropertySnapshot("flag", true), + new ResourcePropertySnapshot("list", new[] { "one", "two" }), + new ResourcePropertySnapshot("ConnectionString", "secret-value") { IsSensitive = true } + ] + }).DefaultTimeout(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services); + + var result = await target.GetResourceSnapshotsAsync().DefaultTimeout(); + + var snapshot = Assert.Single(result); + Assert.Equal("42", Assert.IsAssignableFrom(snapshot.Properties["number"]).GetValue()); + Assert.Equal(bool.TrueString, Assert.IsAssignableFrom(snapshot.Properties["flag"]).GetValue()); + Assert.Equal("one,two", Assert.IsAssignableFrom(snapshot.Properties["list"]).GetValue()); + Assert.Null(snapshot.Properties["ConnectionString"]); + + await app.StopAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + + [Fact] + public async Task GetResourcesAsync_MapsNonStringPropertiesAsJsonForV3Callers() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + var custom = builder.AddResource(new CustomResource("myresource")); + + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + var notificationService = app.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(custom.Resource, s => s with + { + Properties = + [ + new ResourcePropertySnapshot("number", 42), + new ResourcePropertySnapshot("flag", true), + new ResourcePropertySnapshot("list", new[] { "one", "two" }), + new ResourcePropertySnapshot("ConnectionString", "secret-value") { IsSensitive = true } + ] + }).DefaultTimeout(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services); + + var response = await target.GetResourcesAsync(new GetResourcesRequest + { + ClientCapabilities = [AuxiliaryBackchannelCapabilities.V3] + }).DefaultTimeout(); + + var snapshot = Assert.Single(response.Resources); + Assert.Equal(42, Assert.IsAssignableFrom(snapshot.Properties["number"]).GetValue()); + Assert.True(Assert.IsAssignableFrom(snapshot.Properties["flag"]).GetValue()); + var list = Assert.IsAssignableFrom(snapshot.Properties["list"]); + Assert.Collection( + list, + value => Assert.Equal("one", value?.GetValue()), + value => Assert.Equal("two", value?.GetValue())); + Assert.Null(snapshot.Properties["ConnectionString"]); + + await app.StopAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + [Fact] public async Task WaitForResourceAsync_AcceptsResourceId() { diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs index a10fb523d51..2207186f041 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs @@ -452,21 +452,23 @@ public async Task GetCapabilitiesAsyncReturnsCurrentCapabilities() var endpoint = new UnixDomainSocketEndPoint(service.SocketPath); await socket.ConnectAsync(endpoint).DefaultTimeout(); - using var stream = new NetworkStream(socket, ownsSocket: true); - using var rpc = JsonRpc.Attach(stream); + { + using var stream = new NetworkStream(socket, ownsSocket: true); + using var rpc = JsonRpc.Attach(stream); - // Invoke the GetCapabilitiesAsync RPC method - var response = await rpc.InvokeAsync( - "GetCapabilitiesAsync", - new object?[] { null } - ).DefaultTimeout(); + // Invoke the GetCapabilitiesAsync RPC method + var response = await rpc.InvokeAsync( + "GetCapabilitiesAsync", + new object?[] { null } + ).DefaultTimeout(); - // Verify the current capability set. - Assert.NotNull(response); - Assert.NotNull(response.Capabilities); - Assert.Contains(AuxiliaryBackchannelCapabilities.V1, response.Capabilities); - Assert.Contains(AuxiliaryBackchannelCapabilities.V2, response.Capabilities); - Assert.Contains(AuxiliaryBackchannelCapabilities.V3, response.Capabilities); + // Verify the current capability set. + Assert.NotNull(response); + Assert.NotNull(response.Capabilities); + Assert.Contains(AuxiliaryBackchannelCapabilities.V1, response.Capabilities); + Assert.Contains(AuxiliaryBackchannelCapabilities.V2, response.Capabilities); + Assert.Contains(AuxiliaryBackchannelCapabilities.V3, response.Capabilities); + } await app.StopAsync().DefaultTimeout(); } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs index 691f828072e..66e3af8d8df 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs @@ -333,6 +333,11 @@ private static object CreateNonDefaultValue(Type requestType, PropertyInfo prope return propertyName; } + if (property.PropertyType == typeof(string[])) + { + return new[] { propertyName }; + } + if (propertyType == typeof(bool)) { return defaultValue is bool value ? !value : true; @@ -381,6 +386,11 @@ private static bool PropertyValuesEqual(object? expected, object? actual) expectedDictionary.All(item => actualDictionary.TryGetValue(item.Key, out var actualValue) && item.Value == actualValue); } + if (expected is string[] expectedArray && actual is string[] actualArray) + { + return expectedArray.SequenceEqual(actualArray); + } + return Equals(expected, actual); }