// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
using System.Reflection;
using System.Runtime.CompilerServices;
using Aspire.Dashboard.Model;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Utils;
using Aspire.Hosting.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
namespace Aspire.Hosting.Tests;
[Trait("Partition", "3")]
public class ResourceNotificationTests
{
[Fact]
public void InitialStateCanBeSpecified()
{
var builder = DistributedApplication.CreateBuilder();
var custom = builder.AddResource(new CustomResource("myResource"))
.WithEndpoint(name: "ep", scheme: "http", port: 8080)
.WithEnvironment("x", "1000")
.WithInitialState(new()
{
ResourceType = "MyResource",
Properties = [new("A", "B")],
});
var annotation = custom.Resource.Annotations.OfType<ResourceSnapshotAnnotation>().SingleOrDefault();
Assert.NotNull(annotation);
var state = annotation.InitialSnapshot;
Assert.Equal("MyResource", state.ResourceType);
Assert.Empty(state.EnvironmentVariables);
Assert.Collection(state.Properties, c =>
{
Assert.Equal("A", c.Name);
Assert.Equal("B", c.Value);
});
}
[Theory]
[InlineData(typeof(ProjectResource), KnownResourceTypes.Project)]
[InlineData(typeof(ContainerResource), KnownResourceTypes.Container)]
[InlineData(typeof(ExecutableResource), KnownResourceTypes.Executable)]
[InlineData(typeof(ParameterResource), KnownResourceTypes.Parameter)]
[InlineData(typeof(ConnectionStringResource), KnownResourceTypes.ConnectionString)]
[InlineData(typeof(ExternalServiceResource), KnownResourceTypes.ExternalService)]
[InlineData(typeof(CustomResource), "CustomResource")]
public async Task InitialSnapshotResourceTypeMatchesKnownResourceTypes(Type resourceType, string expectedResourceType)
{
IResource resource = resourceType.Name switch
{
nameof(ProjectResource) => new ProjectResource("test"),
nameof(ContainerResource) => new ContainerResource("test"),
nameof(ExecutableResource) => new ExecutableResource("test", "cmd", "."),
nameof(ParameterResource) => new ParameterResource("test", _ => "value", secret: false),
nameof(ConnectionStringResource) => new ConnectionStringResource("test", ReferenceExpression.Create($"connectionString")),
nameof(ExternalServiceResource) => new ExternalServiceResource("test", new Uri("http://localhost/")),
nameof(CustomResource) => new CustomResource("test"),
_ => throw new InvalidOperationException($"Unknown resource type: {resourceType}")
};
var notificationService = ResourceNotificationServiceTestHelpers.Create();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var watchTask = Task.Run(async () =>
{
await foreach (var item in notificationService.WatchAsync(cts.Token))
{
return item;
}
return null;
});
await notificationService.PublishUpdateAsync(resource, state => state).DefaultTimeout();
var resourceEvent = await watchTask.DefaultTimeout();
Assert.NotNull(resourceEvent);
Assert.Equal(expectedResourceType, resourceEvent.Snapshot.ResourceType);
}
[Fact]
public async Task ResourceUpdatesAreQueued()
{
var resource = new CustomResource("myResource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
async Task<List<ResourceEvent>> GetValuesAsync(CancellationToken cancellationToken)
{
var values = new List<ResourceEvent>();
await foreach (var item in notificationService.WatchAsync(cancellationToken))
{
values.Add(item);
if (values.Count == 2)
{
break;
}
}
return values;
}
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var enumerableTask = GetValuesAsync(cts.Token);
await notificationService.PublishUpdateAsync(resource, state => state with { Properties = state.Properties.Add(new("A", "value")) }).DefaultTimeout();
await notificationService.PublishUpdateAsync(resource, state => state with { Properties = state.Properties.Add(new("B", "value")) }).DefaultTimeout();
var values = await enumerableTask.DefaultTimeout();
Assert.Collection(values,
c =>
{
Assert.Equal(resource, c.Resource);
Assert.Equal("myResource", c.ResourceId);
Assert.Equal("CustomResource", c.Snapshot.ResourceType);
Assert.Equal("value", c.Snapshot.Properties.Single(p => p.Name == "A").Value);
Assert.Null(c.Snapshot.HealthStatus);
},
c =>
{
Assert.Equal(resource, c.Resource);
Assert.Equal("myResource", c.ResourceId);
Assert.Equal("CustomResource", c.Snapshot.ResourceType);
Assert.Equal("value", c.Snapshot.Properties.Single(p => p.Name == "B").Value);
Assert.Null(c.Snapshot.HealthStatus);
});
}
[Fact]
public async Task PublishedHealthReportsUpdateHealthStatus()
{
var resource = new CustomResource("myResource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, snapshot =>
(snapshot with { State = KnownResourceStates.Running }).WithHealthReports(
[
new HealthReportSnapshot("browser-session", HealthStatus.Unhealthy, "Browser session failed.", "InvalidOperationException: Browser crashed.")
])).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var resourceEvent));
Assert.Equal(HealthStatus.Unhealthy, resourceEvent.Snapshot.HealthStatus);
Assert.Collection(
resourceEvent.Snapshot.HealthReports,
report =>
{
Assert.Equal("browser-session", report.Name);
Assert.Equal(HealthStatus.Unhealthy, report.Status);
Assert.Equal("Browser session failed.", report.Description);
Assert.Equal("InvalidOperationException: Browser crashed.", report.ExceptionText);
});
}
[Fact]
public async Task WatchingAllResourcesNotifiesOfAnyResourceChange()
{
var resource1 = new CustomResource("myResource1");
var resource2 = new CustomResource("myResource2");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
async Task<List<ResourceEvent>> GetValuesAsync(CancellationToken cancellation)
{
var values = new List<ResourceEvent>();
await foreach (var item in notificationService.WatchAsync(cancellation))
{
values.Add(item);
if (values.Count == 3)
{
break;
}
}
return values;
}
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var enumerableTask = GetValuesAsync(cts.Token);
await notificationService.PublishUpdateAsync(resource1, state => state with { Properties = state.Properties.Add(new("A", "value")) }).DefaultTimeout();
await notificationService.PublishUpdateAsync(resource2, state => state with { Properties = state.Properties.Add(new("B", "value")) }).DefaultTimeout();
await notificationService.PublishUpdateAsync(resource1, "replica1", state => state with { Properties = state.Properties.Add(new("C", "value")) }).DefaultTimeout();
var values = await enumerableTask.DefaultTimeout();
Assert.Collection(values,
c =>
{
Assert.Equal(resource1, c.Resource);
Assert.Equal("myResource1", c.ResourceId);
Assert.Equal("CustomResource", c.Snapshot.ResourceType);
Assert.Equal("value", c.Snapshot.Properties.Single(p => p.Name == "A").Value);
},
c =>
{
Assert.Equal(resource2, c.Resource);
Assert.Equal("myResource2", c.ResourceId);
Assert.Equal("CustomResource", c.Snapshot.ResourceType);
Assert.Equal("value", c.Snapshot.Properties.Single(p => p.Name == "B").Value);
},
c =>
{
Assert.Equal(resource1, c.Resource);
Assert.Equal("replica1", c.ResourceId);
Assert.Equal("CustomResource", c.Snapshot.ResourceType);
Assert.Equal("value", c.Snapshot.Properties.Single(p => p.Name == "C").Value);
});
}
[Fact]
public async Task WaitingOnResourceReturnsWhenResourceReachesTargetState()
{
var resource1 = new CustomResource("myResource1");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState");
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(waitTask.IsCompletedSuccessfully);
}
[Fact]
public async Task WaitingOnResourceReturnsWhenResourceReachesTargetStateWithDifferentCasing()
{
var resource1 = new CustomResource("myResource1");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForResourceAsync("MYreSouRCe1", "sOmeSTAtE", cts.Token);
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(waitTask.IsCompletedSuccessfully);
}
[Fact]
public async Task WaitingOnResourceReturnsImmediatelyWhenResourceIsInTargetStateAlready()
{
var resource1 = new CustomResource("myResource1");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
// Publish the state update first
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState");
Assert.True(waitTask.IsCompletedSuccessfully);
}
[Fact]
public async Task WaitingOnResourceReturnsWhenResourceReachesRunningStateIfNoTargetStateSupplied()
{
var resource1 = new CustomResource("myResource1");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
var waitTask = notificationService.WaitForResourceAsync("myResource1", targetState: null);
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = KnownResourceStates.Running }).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(waitTask.IsCompletedSuccessfully);
}
[Fact]
public async Task WaitingOnResourceReturnsCorrectStateWhenResourceReachesOneOfTargetStatesBeforeCancellation()
{
var resource1 = new CustomResource("myResource1");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
var waitTask = notificationService.WaitForResourceAsync("myResource1", ["SomeState", "SomeOtherState"]);
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeOtherState" }).DefaultTimeout();
var reachedState = await waitTask.DefaultTimeout();
Assert.Equal("SomeOtherState", reachedState);
}
[Fact]
public async Task WaitingOnResourceReturnsCorrectStateWhenResourceReachesOneOfTargetStates()
{
var resource1 = new CustomResource("myResource1");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
var waitTask = notificationService.WaitForResourceAsync("myResource1", ["SomeState", "SomeOtherState"], default);
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeOtherState" }).DefaultTimeout();
var reachedState = await waitTask.DefaultTimeout();
Assert.Equal("SomeOtherState", reachedState);
}
[Fact]
public async Task WaitingOnResourceReturnsItReachesStateAfterApplicationStoppingCancellationTokenSignaled()
{
var resource1 = new CustomResource("myResource1");
using var hostApplicationLifetime = new TestHostApplicationLifetime();
var notificationService = ResourceNotificationServiceTestHelpers.Create(hostApplicationLifetime: hostApplicationLifetime);
var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState");
hostApplicationLifetime.StopApplication();
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(waitTask.IsCompletedSuccessfully);
}
[Fact]
public async Task WaitingOnResourceThrowsOperationCanceledExceptionIfResourceDoesntReachStateBeforeCancellationTokenSignaled()
{
var notificationService = ResourceNotificationServiceTestHelpers.Create();
using var cts = new CancellationTokenSource();
var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState", cts.Token);
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await waitTask;
}).DefaultTimeout();
}
[Fact]
public async Task WaitForDependenciesPublishesAndUpdatesWaitingForDependencies()
{
var dependency1 = new CustomResource("dependency1");
var dependency2 = new CustomResource("dependency2");
var resource = new CustomResource("resource");
resource.Annotations.Add(new WaitAnnotation(dependency1, WaitType.WaitUntilStarted));
resource.Annotations.Add(new WaitAnnotation(dependency2, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
var waitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { dependency1.Name, dependency2.Name }),
cts.Token).DefaultTimeout();
Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text);
await notificationService.PublishUpdateAsync(dependency1, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
var updatedWaitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { dependency2.Name }),
cts.Token).DefaultTimeout();
Assert.Equal(new[] { dependency2.Name }, GetWaitingForDependencies(updatedWaitingEvent));
await notificationService.PublishUpdateAsync(dependency2, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var completedWaitingEvent));
Assert.DoesNotContain(completedWaitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor);
}
[Fact]
public async Task WaitForDependenciesPublishesAndUpdatesWaitingForHealthyDependencies()
{
var dependency1 = new CustomResource("dependency1");
dependency1.Annotations.Add(new HealthCheckAnnotation("dependency1-health"));
var dependency2 = new CustomResource("dependency2");
dependency2.Annotations.Add(new HealthCheckAnnotation("dependency2-health"));
var resource = new CustomResource("resource");
resource.Annotations.Add(new WaitAnnotation(dependency1, WaitType.WaitUntilHealthy));
resource.Annotations.Add(new WaitAnnotation(dependency2, WaitType.WaitUntilHealthy));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
var waitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { dependency1.Name, dependency2.Name }),
cts.Token).DefaultTimeout();
Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text);
await notificationService.PublishUpdateAsync(dependency1, s =>
(s with
{
State = KnownResourceStates.Running,
ResourceReadyEvent = new EventSnapshot(Task.CompletedTask)
}).WithHealthReports(
[
new HealthReportSnapshot("dependency1-health", HealthStatus.Healthy, "Dependency is healthy.", null)
])).DefaultTimeout();
var updatedWaitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { dependency2.Name }),
cts.Token).DefaultTimeout();
Assert.Equal(new[] { dependency2.Name }, GetWaitingForDependencies(updatedWaitingEvent));
await notificationService.PublishUpdateAsync(dependency2, s =>
(s with
{
State = KnownResourceStates.Running,
ResourceReadyEvent = new EventSnapshot(Task.CompletedTask)
}).WithHealthReports(
[
new HealthReportSnapshot("dependency2-health", HealthStatus.Healthy, "Dependency is healthy.", null)
])).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var completedWaitingEvent));
Assert.DoesNotContain(completedWaitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor);
}
[Fact]
public async Task WaitForDependenciesPublishesAndUpdatesWaitingForCompletionDependencies()
{
var dependency1 = new CustomResource("dependency1");
var dependency2 = new CustomResource("dependency2");
var resource = new CustomResource("resource");
resource.Annotations.Add(new WaitAnnotation(dependency1, WaitType.WaitForCompletion));
resource.Annotations.Add(new WaitAnnotation(dependency2, WaitType.WaitForCompletion));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
var waitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { dependency1.Name, dependency2.Name }),
cts.Token).DefaultTimeout();
Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text);
await notificationService.PublishUpdateAsync(dependency1, s => s with
{
State = KnownResourceStates.Finished,
ExitCode = 0
}).DefaultTimeout();
var updatedWaitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { dependency2.Name }),
cts.Token).DefaultTimeout();
Assert.Equal(new[] { dependency2.Name }, GetWaitingForDependencies(updatedWaitingEvent));
await notificationService.PublishUpdateAsync(dependency2, s => s with
{
State = KnownResourceStates.Finished,
ExitCode = 0
}).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var completedWaitingEvent));
Assert.DoesNotContain(completedWaitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor);
}
[Fact]
public async Task WaitForDependenciesTransitionsNotStartedResourceToWaiting()
{
// Custom resources that drive their own startup publish BeforeResourceStartedEvent from
// OnInitializeResource, so they are still in the "NotStarted" state supplied by WithInitialState
// when the dependency wait is published. https://github.com/microsoft/aspire/issues/17453
var dependency = new CustomResource("dependency");
var resource = new CustomResource("resource");
resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, s => s with
{
State = KnownResourceStates.NotStarted
}).DefaultTimeout();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
var waitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting,
cts.Token).DefaultTimeout();
Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text);
Assert.Equal(new[] { dependency.Name }, GetWaitingForDependencies(waitingEvent));
await notificationService.PublishUpdateAsync(dependency, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await waitTask.DefaultTimeout();
}
[Theory]
[InlineData(nameof(KnownResourceStates.Running))]
[InlineData(nameof(KnownResourceStates.Stopping))]
[InlineData(nameof(KnownResourceStates.Building))]
[InlineData(nameof(KnownResourceStates.Finished))]
[InlineData(nameof(KnownResourceStates.Exited))]
[InlineData(nameof(KnownResourceStates.FailedToStart))]
public async Task WaitForDependenciesDoesNotTransitionActiveOrTerminalResourceToWaiting(string state)
{
// The dependency wait is a model-level update, so it reaches every replica of the resource.
// Replicas that already started, are stopping, are being rebuilt, or have terminated must keep
// their state. In particular the rebuild command puts replicas into "Building" and relies on
// that surviving while a sibling replica is still waiting on dependencies.
var dependency = new CustomResource("dependency");
var resource = new CustomResource("resource");
resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, s => s with { State = state }).DefaultTimeout();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
await notificationService.PublishUpdateAsync(dependency, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var resourceEvent));
Assert.Equal(state, resourceEvent.Snapshot.State?.Text);
}
[Fact]
public async Task WaitForDependenciesDoesNotTransitionNotStartedReplicaToWaiting()
{
// Replicas of an explicit start resource are started individually, so a replica that is still "NotStarted"
// while a sibling is starting was deliberately not started and must keep its state, even though the wait is
// published as a model-level update that reaches every replica.
var dependency = new CustomResource("dependency");
var resource = new CustomResource("resource");
resource.Annotations.Add(new ExplicitStartupAnnotation());
resource.Annotations.Add(new DcpInstancesAnnotation([
new DcpInstance("resource-abc123", "abc123", 0),
new DcpInstance("resource-def456", "def456", 1)
]));
resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, "resource-abc123", s => s with
{
State = KnownResourceStates.Starting
}).DefaultTimeout();
await notificationService.PublishUpdateAsync(resource, "resource-def456", s => s with
{
State = KnownResourceStates.NotStarted
}).DefaultTimeout();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
await notificationService.WaitForResourceAsync(
resource.Name,
re => re.ResourceId == "resource-abc123" && re.Snapshot.State?.Text == KnownResourceStates.Waiting,
cts.Token).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("resource-def456", out var notStartedEvent));
Assert.Equal(KnownResourceStates.NotStarted, notStartedEvent.Snapshot.State?.Text);
await notificationService.PublishUpdateAsync(dependency, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("resource-def456", out notStartedEvent));
Assert.Equal(KnownResourceStates.NotStarted, notStartedEvent.Snapshot.State?.Text);
}
[Fact]
public async Task WaitForDependenciesDoesNotTransitionNotStartedExplicitStartResourceToWaiting()
{
var dependency = new CustomResource("dependency");
var resource = new CustomResource("resource");
resource.Annotations.Add(new ExplicitStartupAnnotation());
resource.Annotations.Add(new DcpInstancesAnnotation([new DcpInstance("resource-abc123", "abc123", 0)]));
resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, "resource-abc123", s => s with
{
State = KnownResourceStates.NotStarted
}).DefaultTimeout();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
await notificationService.PublishUpdateAsync(dependency, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("resource-abc123", out var notStartedEvent));
Assert.Equal(KnownResourceStates.NotStarted, notStartedEvent.Snapshot.State?.Text);
}
[Fact]
public async Task WaitForDependenciesTransitionsNotStartedExplicitStartResourceWithoutDcpInstancesToWaiting()
{
// Resources not managed by Aspire (no instances) should transition to "Waiting" directly from NotStarted,
// regardless whether they have ExplicitStartAnnotation or not. The annotation does not really makes a difference here
// because it is only used to control the behavior of the orchestrator when starting a resource
// and since the resource has no instances, it is not subject to the orchestrator's start logic.
var dependency = new CustomResource("dependency");
var resource = new CustomResource("resource");
resource.Annotations.Add(new ExplicitStartupAnnotation());
resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, s => s with
{
State = KnownResourceStates.NotStarted
}).DefaultTimeout();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
var waitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting,
cts.Token).DefaultTimeout();
Assert.Equal(new[] { dependency.Name }, GetWaitingForDependencies(waitingEvent));
await notificationService.PublishUpdateAsync(dependency, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await waitTask.DefaultTimeout();
}
[Fact]
public async Task WaitForDependenciesTransitionsNotStartedInstanceWithoutExplicitStartToWaiting()
{
// The guard is scoped to explicit start rather than to whether the resource has DCP instances. An instance
// that is "NotStarted" without the user having asked for that is simply about to start, so it must still get
// the "Waiting" label - otherwise the dashboard would under-report a resource that really is blocked.
var dependency = new CustomResource("dependency");
var resource = new CustomResource("resource");
resource.Annotations.Add(new DcpInstancesAnnotation([
new DcpInstance("resource-abc123", "abc123", 0),
new DcpInstance("resource-def456", "def456", 1)
]));
resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, "resource-abc123", s => s with
{
State = KnownResourceStates.NotStarted
}).DefaultTimeout();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
var waitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.ResourceId == "resource-abc123" && re.Snapshot.State?.Text == KnownResourceStates.Waiting,
cts.Token).DefaultTimeout();
Assert.Equal(new[] { dependency.Name }, GetWaitingForDependencies(waitingEvent));
await notificationService.PublishUpdateAsync(dependency, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await waitTask.DefaultTimeout();
}
[Fact]
public async Task WaitForDependenciesPublishesResolvedWaitingForDependenciesForReplicas()
{
var dependency = new CustomResource("dependency");
dependency.Annotations.Add(new DcpInstancesAnnotation([
new DcpInstance("dependency-abc123", "abc123", 0),
new DcpInstance("dependency-def456", "def456", 1)
]));
var resource = new CustomResource("resource");
resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
var waitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { "dependency-abc123", "dependency-def456" }),
cts.Token).DefaultTimeout();
Assert.Equal(new[] { "dependency-abc123", "dependency-def456" }, GetWaitingForDependencies(waitingEvent));
await notificationService.PublishUpdateAsync(dependency, "dependency-abc123", s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
var partialWaitingEvent = await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { "dependency-def456" }),
cts.Token).DefaultTimeout();
Assert.Equal(new[] { "dependency-def456" }, GetWaitingForDependencies(partialWaitingEvent));
await notificationService.PublishUpdateAsync(dependency, "dependency-def456", s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await waitTask.DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var completedWaitingEvent));
Assert.DoesNotContain(completedWaitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor);
}
[Fact]
public async Task PublishUpdateClearsWaitingForDependenciesWhenResourceLeavesWaiting()
{
var resource = new CustomResource("resource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, s => s with
{
State = KnownResourceStates.Waiting,
Properties = [new ResourcePropertySnapshot(KnownProperties.Resource.WaitingFor, new[] { "dependency" })]
}).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var waitingEvent));
Assert.Contains(waitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor);
await notificationService.PublishUpdateAsync(resource, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var runningEvent));
Assert.DoesNotContain(runningEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor);
}
[Fact]
public async Task CancellationMessageIncludesWaitingForDependencies()
{
var resource = new CustomResource("resource");
var dependency = new CustomResource("dependency");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(dependency, s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
await notificationService.PublishUpdateAsync(resource, s => s with
{
State = KnownResourceStates.Waiting,
Properties = [new ResourcePropertySnapshot(KnownProperties.Resource.WaitingFor, new[] { dependency.Name })]
}).DefaultTimeout();
using var cts = new CancellationTokenSource();
var waitTask = notificationService.WaitForResourceAsync(resource.Name, KnownResourceStates.Running, cts.Token);
await cts.CancelAsync();
var ex = await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await waitTask;
}).DefaultTimeout();
Assert.Contains("- Waiting For:", ex.Message);
Assert.Contains(" - dependency: State = Running, Health = Healthy", ex.Message);
}
[Fact]
public async Task WaitForDependenciesCancellationMessageIncludesWaitingForDependencies()
{
var resource = new CustomResource("resource");
var dependency = new CustomResource("dependency");
resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(dependency, s => s with
{
State = KnownResourceStates.Starting
}).DefaultTimeout();
using var cts = new CancellationTokenSource();
var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token);
await notificationService.WaitForResourceAsync(
resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting &&
GetWaitingForDependencies(re).SequenceEqual(new[] { dependency.Name }),
TestContext.Current.CancellationToken).DefaultTimeout();
await cts.CancelAsync();
var ex = await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await waitTask;
}).DefaultTimeout();
Assert.Contains("Resource 'resource' failed to wait for dependencies before the operation was cancelled.", ex.Message);
Assert.Contains("- Waiting For:", ex.Message);
Assert.Contains(" - dependency: State = Starting", ex.Message);
}
[Fact]
public async Task WaitingOnResourceThrowsOperationCanceledExceptionIfResourceDoesntReachStateBeforeServiceIsDisposed()
{
var notificationService = ResourceNotificationServiceTestHelpers.Create();
var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState");
notificationService.Dispose();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await waitTask;
}).DefaultTimeout();
}
[Fact]
public async Task WaitingOnResourceThrowsOperationCanceledExceptionIfResourceDoesntReachStateBeforeCancellationTokenSignalledWhenApplicationStoppingTokenExists()
{
using var hostApplicationLifetime = new TestHostApplicationLifetime();
var notificationService = ResourceNotificationServiceTestHelpers.Create(hostApplicationLifetime: hostApplicationLifetime);
using var cts = new CancellationTokenSource();
var waitTask = notificationService.WaitForResourceAsync("myResource1", "SomeState", cts.Token);
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await waitTask;
}).DefaultTimeout();
}
[Fact]
public async Task PublishLogsStateTextChangesCorrectly()
{
var resource1 = new CustomResource("resource1");
var logger = new FakeLogger<ResourceNotificationService>();
var notificationService = ResourceNotificationServiceTestHelpers.Create(logger: logger);
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
var logs = logger.Collector.GetSnapshot();
// Initial state text, log just the new state
Assert.Single(logs, l => l.Level == LogLevel.Debug);
Assert.Contains(logs, l => l.Level == LogLevel.Debug && l.Message.Contains("Resource resource1/resource1 changed state: SomeState"));
logger.Collector.Clear();
// Same state text as previous state, no log
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
logs = logger.Collector.GetSnapshot();
Assert.DoesNotContain(logs, l => l.Level == LogLevel.Debug);
Assert.DoesNotContain(logs, l => l.Level == LogLevel.Debug && l.Message.Contains("Resource resource1/resource1 changed state: SomeState"));
logger.Collector.Clear();
// Different state text, log the transition from the previous state to the new state
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "NewState" }).DefaultTimeout();
logs = logger.Collector.GetSnapshot();
Assert.Single(logs, l => l.Level == LogLevel.Debug);
Assert.Contains(logs, l => l.Level == LogLevel.Debug && l.Message.Contains("Resource resource1/resource1 changed state: SomeState -> NewState"));
logger.Collector.Clear();
// Null state text, no log
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = null }).DefaultTimeout();
logs = logger.Collector.GetSnapshot();
Assert.DoesNotContain(logs, l => l.Level == LogLevel.Debug);
Assert.DoesNotContain(logs, l => l.Level == LogLevel.Debug && l.Message.Contains("Resource resource1/resource1 changed state:"));
logger.Collector.Clear();
// Empty state text, no log
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "" }).DefaultTimeout();
logs = logger.Collector.GetSnapshot();
Assert.DoesNotContain(logs, l => l.Level == LogLevel.Debug);
Assert.DoesNotContain(logs, l => l.Level == LogLevel.Debug && l.Message.Contains("Resource resource1/resource1 changed state:"));
logger.Collector.Clear();
// White space state text, no log
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = " " }).DefaultTimeout();
logs = logger.Collector.GetSnapshot();
Assert.DoesNotContain(logs, l => l.Level == LogLevel.Debug);
Assert.DoesNotContain(logs, l => l.Level == LogLevel.Debug && l.Message.Contains("Resource resource1/resource1 changed state:"));
logger.Collector.Clear();
}
[Fact]
public async Task PublishLogsTraceStateDetailsCorrectly()
{
var resource1 = new CustomResource("resource1");
var logger = new FakeLogger<ResourceNotificationService>();
var notificationService = ResourceNotificationServiceTestHelpers.Create(logger: logger);
var createdDate = DateTime.Now;
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { CreationTimeStamp = createdDate }).DefaultTimeout();
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
await notificationService.PublishUpdateAsync(resource1, snapshot => snapshot with { ExitCode = 0 }).DefaultTimeout();
var logs = logger.Collector.GetSnapshot();
Assert.Single(logs, l => l.Level == LogLevel.Debug);
Assert.Equal(3, logs.Where(l => l.Level == LogLevel.Trace).Count());
Assert.Contains(logs, l => l.Level == LogLevel.Trace && l.Message.Contains("Resource resource1/resource1 update published:") && l.Message.Contains($"CreationTimeStamp = {createdDate:s}"));
Assert.Contains(logs, l => l.Level == LogLevel.Trace && l.Message.Contains("Resource resource1/resource1 update published:") && l.Message.Contains("State = { Text = SomeState"));
Assert.Contains(logs, l => l.Level == LogLevel.Trace && l.Message.Contains("Resource resource1/resource1 update published:") && l.Message.Contains("ExitCode = 0"));
}
[Fact]
public void IsMicrosoftOpenType_ReturnsFalse_ForNonMicrosoftResourceTypes()
{
var resourceTypes = new[]
{
typeof(XunitDelayEnumeratedTheoryTestCase),
typeof(Polly.DelayBackoffType),
};
foreach (var type in resourceTypes)
{
var result = ResourceNotificationService.IsMicrosoftOpenType(type);
Assert.False(result, $"Expected {type.Name} to not be a Microsoft OpenType, but it was.");
}
}
[Fact]
public void IsMicrosoftOpenType_ReturnsTrue_ForAspireTypes()
{
var resourceTypes = new[]
{
typeof(CustomResource),
typeof(ContainerResource),
typeof(PostgresServerResource)
};
foreach (var type in resourceTypes)
{
var result = ResourceNotificationService.IsMicrosoftOpenType(type);
Assert.True(result);
}
}
[Fact]
public async Task UpdateIcons_DoesNotOverwriteExistingIconValues()
{
var resource = new CustomResource("myResource");
// Add multiple icon annotations to test the override behavior
resource.Annotations.Add(new ResourceIconAnnotation("FirstIcon", IconVariant.Filled));
resource.Annotations.Add(new ResourceIconAnnotation("LastIcon", IconVariant.Regular));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
async Task<List<ResourceEvent>> GetValuesAsync(CancellationToken cancellationToken)
{
var values = new List<ResourceEvent>();
await foreach (var item in notificationService.WatchAsync(cancellationToken))
{
values.Add(item);
if (values.Count == 2)
{
break;
}
}
return values;
}
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var enumerableTask = GetValuesAsync(cts.Token);
// First, publish an update with existing icon values in the snapshot
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
IconName = "ExistingIcon",
IconVariant = IconVariant.Filled
}).DefaultTimeout();
// Publish another update that should NOT overwrite the existing icon values
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = "Running" // Change something else to trigger an update
}).DefaultTimeout();
var values = await enumerableTask.DefaultTimeout();
Assert.Equal(2, values.Count);
// Check the first event (with initial icon values)
var firstEvent = values[0];
Assert.Equal("ExistingIcon", firstEvent.Snapshot.IconName);
Assert.Equal(IconVariant.Filled, firstEvent.Snapshot.IconVariant);
// Check the second event (icon values should not be overwritten)
var secondEvent = values[1];
Assert.Equal("ExistingIcon", secondEvent.Snapshot.IconName);
Assert.Equal(IconVariant.Filled, secondEvent.Snapshot.IconVariant);
Assert.Equal("Running", secondEvent.Snapshot.State?.Text);
}
[Fact]
public async Task UpdateIcons_UsesLastAnnotationWhenNoIconSet()
{
var resource = new CustomResource("myResource");
// Add multiple icon annotations to simulate .WithIconName("FirstIcon").WithIconName("LastIcon")
resource.Annotations.Add(new ResourceIconAnnotation("FirstIcon", IconVariant.Filled));
resource.Annotations.Add(new ResourceIconAnnotation("LastIcon", IconVariant.Regular));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
async Task<ResourceEvent> GetFirstValueAsync(CancellationToken cancellationToken)
{
await foreach (var item in notificationService.WatchAsync(cancellationToken))
{
return item;
}
throw new InvalidOperationException("No events received");
}
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var enumerableTask = GetFirstValueAsync(cts.Token);
// Publish an update with no existing icon values (simulates initial resource creation)
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = "Starting"
}).DefaultTimeout();
var value = await enumerableTask.DefaultTimeout();
// Verify that the icon values were set from the LAST annotation (not the first)
Assert.Equal("LastIcon", value.Snapshot.IconName);
Assert.Equal(IconVariant.Regular, value.Snapshot.IconVariant);
Assert.Equal("Starting", value.Snapshot.State?.Text);
}
[Fact]
public async Task UpdateIcons_SetsIconValuesWhenNotAlreadySet()
{
var resource = new CustomResource("myResource");
// Add icon annotation to the resource
resource.Annotations.Add(new ResourceIconAnnotation("AnnotationIcon", IconVariant.Regular));
var notificationService = ResourceNotificationServiceTestHelpers.Create();
async Task<ResourceEvent> GetFirstValueAsync(CancellationToken cancellationToken)
{
await foreach (var item in notificationService.WatchAsync(cancellationToken))
{
return item;
}
throw new InvalidOperationException("No events received");
}
using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
var enumerableTask = GetFirstValueAsync(cts.Token);
// Publish an update with no existing icon values
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = "Starting"
}).DefaultTimeout();
var value = await enumerableTask.DefaultTimeout();
// Verify that the icon values were set from the annotation
Assert.Equal("AnnotationIcon", value.Snapshot.IconName);
Assert.Equal(IconVariant.Regular, value.Snapshot.IconVariant);
Assert.Equal("Starting", value.Snapshot.State?.Text);
}
[Fact]
public async Task WithHidden_AlwaysHidden()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resourceBuilder = builder.AddResource(new CustomResource("myResource"))
.WithHidden();
var notificationService = ResourceNotificationServiceTestHelpers.Create();
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Starting));
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.FailedToStart, exitCode: 1));
}
[Fact]
public async Task WithHiddenOnCompletion_HidesOnSuccessfulCompletion()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resourceBuilder = builder.AddResource(new CustomResource("myResource"))
.WithHiddenOnCompletion();
var notificationService = ResourceNotificationServiceTestHelpers.Create();
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Running));
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Exited, exitCode: 1));
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Finished, exitCode: 1));
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Exited, exitCode: 0));
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Finished, exitCode: 0));
}
[Fact]
public async Task WithHiddenOnCompletion_HidesOnSuccessfulCompletionWithCustomExitCodes()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resourceBuilder = builder.AddResource(new CustomResource("myResource"))
.WithHiddenOnCompletion(123,456,789);
var notificationService = ResourceNotificationServiceTestHelpers.Create();
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Running));
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Exited, exitCode: 1));
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Finished, exitCode: 1));
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Exited, exitCode: 456));
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Finished, exitCode: 456));
}
[Fact]
public async Task WithHiddenOnCompletion_WithCustomExitCode_HidesOnMatchingCode()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resourceBuilder = builder.AddResource(new CustomResource("myResource"))
.WithHiddenOnCompletion(5);
var notificationService = ResourceNotificationServiceTestHelpers.Create();
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Finished, exitCode: 0));
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Finished, exitCode: 5));
}
[Fact]
public async Task WithHiddenOnCompletion_WithCustomExitCodes_HidesOnAnyMatchingCode()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resourceBuilder = builder.AddResource(new CustomResource("myResource"))
.WithHiddenOnCompletion(3, 7);
var notificationService = ResourceNotificationServiceTestHelpers.Create();
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Exited, exitCode: 2));
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Exited, exitCode: 7));
}
[Fact]
public async Task WithHiddenOnCompletion_BecomesVisibleOnRestart()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resourceBuilder = builder.AddResource(new CustomResource("myResource"))
.WithHiddenOnCompletion();
var notificationService = ResourceNotificationServiceTestHelpers.Create();
// Resource starts — should be visible
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Running));
// Resource completes successfully — should be hidden
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Finished, exitCode: 0));
// Resource is restarted — exit code may still be set from previous run; should become visible
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Starting, exitCode: 0));
// Resource is running again — should remain visible
Assert.False(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Running));
// Resource completes again — should be hidden again
Assert.True(await PublishAndGetIsHiddenAsync(notificationService, resourceBuilder, KnownResourceStates.Finished, exitCode: 0));
}
[Fact]
public async Task WaitForResourceHealthyAsyncWaitsForResourceReadyEvent()
{
var resource = new CustomResource("myResource");
var logger = new FakeLogger<ResourceNotificationService>();
var notificationService = ResourceNotificationServiceTestHelpers.Create(logger: logger);
// Create a TaskCompletionSource to control when the ResourceReadyEvent completes
var resourceReadyTcs = new TaskCompletionSource();
var eventSnapshot = new EventSnapshot(resourceReadyTcs.Task);
// Start the wait task - this should not complete until ResourceReadyEvent is done
var waitTask = notificationService.WaitForResourceHealthyAsync("myResource");
// First, make the resource running (which makes it healthy) but without ResourceReadyEvent
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
// Now add the ResourceReadyEvent but don't complete it yet
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Running,
ResourceReadyEvent = eventSnapshot
}).DefaultTimeout();
// Complete the ResourceReadyEvent
resourceReadyTcs.SetResult();
// Now the wait task should complete
var resourceEvent = await waitTask.DefaultTimeout();
var logRecords = logger.Collector.GetSnapshot();
Assert.True(waitTask.IsCompletedSuccessfully);
Assert.Equal(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy, resourceEvent.Snapshot.HealthStatus);
Assert.NotNull(resourceEvent.Snapshot.ResourceReadyEvent);
Assert.True(resourceEvent.Snapshot.ResourceReadyEvent.EventTask.IsCompletedSuccessfully);
// Assert logs
Assert.Contains(logRecords, log => log.Level == LogLevel.Debug && log.Message.Contains("Waiting for resource 'myResource' to enter the 'Healthy' state."));
Assert.Contains(logRecords, log => log.Level == LogLevel.Debug && log.Message.Contains("Waiting for resource ready to execute for 'myResource'."));
Assert.Contains(logRecords, log => log.Level == LogLevel.Debug && log.Message.Contains("Finished waiting for resource 'myResource'."));
}
[Fact]
public async Task WaitForResourceHealthyAsyncWaitsForResourceReadyEventWithException()
{
var resource = new CustomResource("myResource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
// Create a TaskCompletionSource that will throw an exception
var resourceReadyTcs = new TaskCompletionSource();
var eventSnapshot = new EventSnapshot(resourceReadyTcs.Task);
// Start the wait task
var waitTask = notificationService.WaitForResourceHealthyAsync("myResource");
// Make the resource running (healthy) and add ResourceReadyEvent
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Running,
ResourceReadyEvent = eventSnapshot
}).DefaultTimeout();
// Set an exception in the ResourceReadyEvent
resourceReadyTcs.SetException(new InvalidOperationException("ResourceReady failed"));
// The wait task should propagate the exception
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => waitTask.DefaultTimeout());
Assert.Equal("ResourceReady failed", ex.Message);
}
[Fact]
public async Task WaitForResourceHealthyAsyncWorksWithoutResourceReadyEvent()
{
var resource = new CustomResource("myResource");
var logger = new FakeLogger<ResourceNotificationService>();
var notificationService = ResourceNotificationServiceTestHelpers.Create(logger: logger);
// Start the wait task
var waitTask = notificationService.WaitForResourceHealthyAsync("myResource");
// Make the resource running (healthy) without ResourceReadyEvent
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
// Now publish an update with ResourceReadyEvent that's already completed
// In practice, this represents a resource that doesn't have OnResourceReady handlers
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Running,
ResourceReadyEvent = new EventSnapshot(Task.CompletedTask)
}).DefaultTimeout();
// Now the wait task should complete
var resourceEvent = await waitTask.DefaultTimeout();
var logRecords = logger.Collector.GetSnapshot();
Assert.True(waitTask.IsCompletedSuccessfully);
Assert.Equal(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy, resourceEvent.Snapshot.HealthStatus);
Assert.Contains(logRecords, log => log.Level == LogLevel.Debug && log.Message.Contains("Waiting for resource 'myResource' to enter the 'Healthy' state."));
Assert.Contains(logRecords, log => log.Level == LogLevel.Debug && log.Message.Contains("Waiting for resource ready to execute for 'myResource'."));
Assert.Contains(logRecords, log => log.Level == LogLevel.Debug && log.Message.Contains("Finished waiting for resource 'myResource'."));
}
[Fact]
public async Task WaitForResourceHealthyAsync_StopOnResourceUnavailable_ThrowsIfResourceNotInModel()
{
var appModel = new DistributedApplicationModel([new CustomResource("existingResource")]);
var serviceProvider = new TestServiceProvider().AddService(appModel);
var notificationService = new ResourceNotificationService(
new NullLogger<ResourceNotificationService>(),
new TestHostApplicationLifetime(),
serviceProvider,
new ResourceLoggerService());
var ex = await Assert.ThrowsAsync<DistributedApplicationException>(
() => notificationService.WaitForResourceHealthyAsync("does-not-exist", WaitBehavior.StopOnResourceUnavailable));
Assert.Contains("does-not-exist", ex.Message);
Assert.Contains("does not exist in the application model", ex.Message);
}
[Fact]
public async Task WaitForResourceHealthyAsync_WaitOnResourceUnavailable_DoesNotThrowForNonexistentResource()
{
var notificationService = ResourceNotificationServiceTestHelpers.Create();
using var cts = new CancellationTokenSource();
// With WaitOnResourceUnavailable the fast-fail path must NOT trigger — the task should
// enter a waiting state rather than throwing DistributedApplicationException immediately.
var waitTask = notificationService.WaitForResourceHealthyAsync("does-not-exist", WaitBehavior.WaitOnResourceUnavailable, cts.Token);
Assert.False(waitTask.IsCompleted);
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() => waitTask);
}
private static async Task<bool> PublishAndGetIsHiddenAsync<T>(
ResourceNotificationService notificationService,
IResourceBuilder<T> resourceBuilder,
string state,
int? exitCode = default) where T : IResource
{
await notificationService.PublishUpdateAsync(resourceBuilder.Resource, snapshot => snapshot with { State = state, ExitCode = exitCode }).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resourceBuilder.Resource.Name, out var resourceEvent));
return resourceEvent!.Snapshot.IsHidden;
}
private sealed class CustomResource(string name) : Resource(name),
IResourceWithEnvironment,
IResourceWithConnectionString,
IResourceWithEndpoints
{
public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create($"CustomConnectionString");
}
private sealed class TestHostApplicationLifetime : IHostApplicationLifetime, IDisposable
{
private readonly CancellationTokenSource _stoppingCts = new();
public TestHostApplicationLifetime()
{
ApplicationStopping = _stoppingCts.Token;
}
public CancellationToken ApplicationStarted { get; }
public CancellationToken ApplicationStopped { get; }
public CancellationToken ApplicationStopping { get; }
public void StopApplication()
{
_stoppingCts.Cancel();
}
public void Dispose()
{
_stoppingCts.Dispose();
}
}
[Fact]
public async Task PublishUpdateAsyncSkipsSnapshotsThatDidNotChange()
{
var resource = new CustomResource("myResource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("myResource", out var initial));
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with { State = "SomeState" }).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("myResource", out var unchanged));
Assert.Equal(initial.Snapshot.Version, unchanged.Snapshot.Version);
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with { State = "SomeOtherState" }).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("myResource", out var changed));
Assert.True(changed.Snapshot.Version > initial.Snapshot.Version, "A real change should publish a new version.");
Assert.Equal("SomeOtherState", changed.Snapshot.State?.Text);
}
[Fact]
public async Task PublishUpdateAsyncSkipsUnchangedSnapshotsWithRebuiltCollections()
{
var resource = new CustomResource("myResource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
// UpdateCommands rebuilds command snapshots while retaining the annotation-owned arguments.
// Keep that same ownership shape here rather than defining deep equality for mutable inputs.
var commandArguments = Array.Empty<InteractionInput>();
// Producers rebuild the snapshot collections every time. ImmutableArray equality compares the
// underlying array reference, and a property value can itself be a collection, so this is the
// case that plain record equality gets wrong. List<T> is covered explicitly because it does
// not implement IStructuralEquatable: it is the shape DCP uses for effective arguments.
CustomResourceSnapshot BuildSnapshot(CustomResourceSnapshot snapshot) => snapshot with
{
State = "SomeState",
Properties =
[
new("Ports", ImmutableArray.Create(8080, 8081))
{
DisplayName = "Ports",
IsSensitive = true,
IsHighlighted = true,
SortOrder = 1
},
new("Args", new List<string> { "--verbose", "--port", "8080" }),
new("Tags", new[] { "a", "b" })
],
EnvironmentVariables = [new("Key", "Value", IsFromSpec: true)],
Urls =
[
new UrlSnapshot("ep", "http://localhost:8080", IsInternal: false)
{
DisplayProperties = new("Endpoint", 1)
}
],
Volumes = [new("/source", "/target", VolumeMountType.Bind, IsReadOnly: true)],
Commands = [CreateTestCommand(commandArguments)],
Relationships = [new("parent", "Parent")],
HealthReports =
[
new("health", HealthStatus.Healthy, "Healthy", ExceptionText: null)
{
LastRunAt = DateTime.UnixEpoch
}
]
};
await notificationService.PublishUpdateAsync(resource, BuildSnapshot).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("myResource", out var initial));
await notificationService.PublishUpdateAsync(resource, BuildSnapshot).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("myResource", out var unchanged));
Assert.Equal(initial.Snapshot.Version, unchanged.Snapshot.Version);
await notificationService.PublishUpdateAsync(resource, snapshot => BuildSnapshot(snapshot) with
{
Properties =
[
new("Ports", ImmutableArray.Create(8080, 8081)),
new("Args", new List<string> { "--verbose", "--port", "9090" }),
new("Tags", new[] { "a", "b" })
]
}).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState("myResource", out var changed));
Assert.True(changed.Snapshot.Version > initial.Snapshot.Version, "A changed value inside a List<T> property should publish a new version.");
}
[Fact]
public async Task PublishUpdateAsyncIgnoresVersionOnlyChanges()
{
var resource = new CustomResource("myResource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, static snapshot => snapshot).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var initial));
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
Version = snapshot.Version + 100
}).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var unchanged));
Assert.Equal(initial.Snapshot.Version, unchanged.Snapshot.Version);
}
[Theory]
[MemberData(nameof(SnapshotContentPropertyNames))]
public async Task PublishUpdateAsyncPublishesEverySnapshotContentChange(string propertyName)
{
var property = GetContentProperty(
typeof(CustomResourceSnapshot),
propertyName,
s_snapshotContentPropertyExclusions);
var resource = new CustomResource("myResource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, static snapshot => snapshot).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var initial));
await notificationService.PublishUpdateAsync(resource, snapshot => MutateProperty(snapshot, property)).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var changed));
Assert.True(
changed.Snapshot.Version > initial.Snapshot.Version,
$"Changing {nameof(CustomResourceSnapshot)}.{propertyName} should publish a new snapshot.");
}
[Theory]
[MemberData(nameof(ResourcePropertyContentPropertyNames))]
public async Task PublishUpdateAsyncPublishesEveryResourcePropertyContentChange(string propertyName)
{
var property = GetContentProperty(typeof(ResourcePropertySnapshot), propertyName);
var resource = new CustomResource("myResource");
var notificationService = ResourceNotificationServiceTestHelpers.Create();
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
Properties =
[
new ResourcePropertySnapshot("property", new List<string> { "original" })
{
DisplayName = "Property",
SortOrder = 1
}
]
}).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var initial));
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
Properties = [MutateProperty(snapshot.Properties.Single(), property)]
}).DefaultTimeout();
Assert.True(notificationService.TryGetCurrentState(resource.Name, out var changed));
Assert.True(
changed.Snapshot.Version > initial.Snapshot.Version,
$"Changing {nameof(ResourcePropertySnapshot)}.{propertyName} should publish a new snapshot.");
}
private static string[] GetWaitingForDependencies(ResourceEvent resourceEvent)
{
var property = resourceEvent.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.WaitingFor);
return property?.Value is IEnumerable<string> dependencyNames ? dependencyNames.ToArray() : [];
}
public static TheoryData<string> SnapshotContentPropertyNames =>
CreatePropertyTheoryData(typeof(CustomResourceSnapshot), s_snapshotContentPropertyExclusions);
public static TheoryData<string> ResourcePropertyContentPropertyNames =>
CreatePropertyTheoryData(typeof(ResourcePropertySnapshot));
private static readonly string[] s_snapshotContentPropertyExclusions =
[
nameof(CustomResourceSnapshot.Version),
nameof(CustomResourceSnapshot.HealthStatus)
];
private static TheoryData<string> CreatePropertyTheoryData(Type type, params string[] excludedPropertyNames)
{
var data = new TheoryData<string>();
foreach (var property in GetContentProperties(type, excludedPropertyNames))
{
data.Add(property.Name);
}
return data;
}
private static PropertyInfo GetContentProperty(Type type, string propertyName, params string[] excludedPropertyNames)
{
return GetContentProperties(type, excludedPropertyNames).Single(property => property.Name == propertyName);
}
private static PropertyInfo[] GetContentProperties(Type type, params string[] excludedPropertyNames)
{
const BindingFlags Flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
var properties = type.GetProperties(Flags)
// EqualityContract is generated for records and describes their runtime type rather than
// snapshot content. Other get-only properties remain in the data so they fail loudly unless
// their mutation is implemented or they are intentionally excluded.
.Where(property => property.Name != "EqualityContract" ||
property.GetMethod?.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) is not true)
.OrderBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
var unknownExclusions = excludedPropertyNames
.Except(properties.Select(property => property.Name), StringComparer.Ordinal)
.ToArray();
if (unknownExclusions.Length > 0)
{
throw new InvalidOperationException($"Unknown excluded properties for {type}: {string.Join(", ", unknownExclusions)}.");
}
return properties
.Where(property => !excludedPropertyNames.Contains(property.Name, StringComparer.Ordinal))
.ToArray();
}
private static CustomResourceSnapshot MutateProperty(CustomResourceSnapshot snapshot, PropertyInfo property)
{
EnsurePropertyCanBeMutated(property);
var mutated = snapshot with { };
property.SetValue(mutated, CreateDifferentValue(property, property.GetValue(snapshot)));
return mutated;
}
private static ResourcePropertySnapshot MutateProperty(ResourcePropertySnapshot snapshot, PropertyInfo property)
{
EnsurePropertyCanBeMutated(property);
var mutated = snapshot with { };
property.SetValue(mutated, CreateDifferentValue(property, property.GetValue(snapshot)));
return mutated;
}
private static void EnsurePropertyCanBeMutated(PropertyInfo property)
{
if (property.GetSetMethod(nonPublic: true) is null)
{
throw new NotSupportedException(
$"{property.DeclaringType}.{property.Name} cannot be dynamically mutated. " +
$"Implement its mutation or add it to the intentional exclusions.");
}
}
private static object? CreateDifferentValue(PropertyInfo property, object? currentValue)
{
var type = property.PropertyType;
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ImmutableArray<>))
{
var elementType = type.GetGenericArguments()[0];
var addMethod = type.GetMethod(nameof(ImmutableArray<int>.Add), [elementType])
?? throw new InvalidOperationException($"Could not find {type}.{nameof(ImmutableArray<int>.Add)}.");
// A fresh empty array would only change storage identity. Adding an element guarantees the
// snapshot's collection content is different and recursively exercises child record equality.
return addMethod.Invoke(currentValue, [CreateValue(elementType)]);
}
if (currentValue is null)
{
return CreateValue(type);
}
if (Nullable.GetUnderlyingType(type) is not null ||
(!type.IsValueType && new NullabilityInfoContext().Create(property).WriteState == NullabilityState.Nullable))
{
// The current value is non-null, so null is always a distinct value without requiring
// type-specific knowledge about the property's contents.
return null;
}
if (type == typeof(string))
{
return currentValue + "-changed";
}
if (type == typeof(bool))
{
return !(bool)currentValue;
}
if (type.IsEnum)
{
var values = Enum.GetValues(type);
if (values.Length < 2)
{
throw new NotSupportedException($"Cannot create a different value for single-value enum {type}.");
}
var currentIndex = Enumerable.Range(0, values.Length)
.Single(index => Equals(values.GetValue(index), currentValue));
return values.GetValue((currentIndex + 1) % values.Length);
}
var value = CreateValue(type);
if (Equals(currentValue, value))
{
throw new NotSupportedException($"Cannot create a different value for {type}. Extend {nameof(CreateDifferentValue)}.");
}
return value;
}
private static object CreateValue(Type type)
{
if (Nullable.GetUnderlyingType(type) is { } underlyingType)
{
return CreateValue(underlyingType);
}
if (type == typeof(string))
{
return "__comparison_test";
}
if (type == typeof(object))
{
return new object();
}
if (type == typeof(bool))
{
return true;
}
if (type == typeof(int))
{
return 1;
}
if (type == typeof(long))
{
return 1L;
}
if (type == typeof(DateTime))
{
return DateTime.UnixEpoch;
}
if (type == typeof(Task))
{
return Task.CompletedTask;
}
if (type.IsEnum)
{
return Enum.GetValues(type).GetValue(0)
?? throw new NotSupportedException($"Enum {type} has no values.");
}
// Snapshot child records expose a primary constructor and a generated copy constructor.
// Exclude the copy constructor because value generation has no existing instance to supply.
var constructor = type
.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Where(constructor =>
{
var parameters = constructor.GetParameters();
return parameters.Length != 1 || parameters[0].ParameterType != type;
})
.OrderByDescending(constructor => constructor.GetParameters().Length)
.FirstOrDefault()
?? throw new NotSupportedException($"Cannot create a value for {type}. Extend {nameof(CreateValue)}.");
var arguments = constructor.GetParameters()
.Select(parameter => CreateValue(parameter.ParameterType))
.ToArray();
return constructor.Invoke(arguments);
}
private static ResourceCommandSnapshot CreateTestCommand(IReadOnlyList<InteractionInput> arguments)
{
#pragma warning disable CS0618 // Parameter remains part of the compatibility constructor.
return new("command", ResourceCommandState.Enabled, "Command", DisplayDescription: null, Parameter: null, ConfirmationMessage: null, IconName: null, IconVariant: null, IsHighlighted: false)
{
Arguments = arguments
};
#pragma warning restore CS0618
}
}