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);
}