File: ResourceNotificationTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// 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
    }
}