File: Orchestrator\ApplicationOrchestratorTests.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.
 
#pragma warning disable CS0618 // Retain legacy project orchestration with synthetic metadata.
using Aspire.Dashboard.Model;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Orchestrator;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Tests.Orchestrator;
 
[Trait("Partition", "3")]
public class ApplicationOrchestratorTests(ITestOutputHelper testOutputHelper)
{
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task UrlCallbackCannotWaitForUnfinishedBrowserOrigins(bool ownEndpoint)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var first = builder.AddExecutable("first", "unused", ".").WithHttpEndpoint();
        var second = builder.AddExecutable("second", "unused", ".").WithHttpEndpoint();
        var requested = ownEndpoint ? first : second;
        first.WithUrls(async context =>
        {
            await Task.Yield();
            await requested.GetEndpoint("http").BrowserOrigins.GetValueAsync(context.CancellationToken);
        });
        foreach (var resource in new[] { first, second })
        {
            var endpoint = resource.GetEndpoint("http").EndpointAnnotation;
            endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
        }
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
 
        await orchestrator.PublishEndpointsAllocatedAsync(
            [new(first.Resource, app.Services), new(second.Resource, app.Services)],
            builder.Eventing, EventDispatchBehavior.BlockingSequential, cts.Token).DefaultTimeout();
 
        var error = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
            await first.GetEndpoint("http").BrowserOrigins.GetValueAsync(cts.Token));
        Assert.Equal(
            $"The URL callback for resource 'first' cannot resolve browser origins for endpoint 'http' " +
            $"on resource '{requested.Resource.Name}' before its URLs are finalized. Read browser origins after URL callbacks complete, " +
            "for example in a ResourceEndpointsAllocatedEvent handler.", error.Message);
        Assert.Equal("http://localhost:8080", await second.GetEndpoint("http").BrowserOrigins.GetValueAsync(cts.Token));
    }
 
    [Fact]
    public async Task UrlCallbackCanReadAlreadyFinalizedBrowserOrigins()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var first = builder.AddExecutable("first", "unused", ".").WithHttpEndpoint()
            .WithUrlForEndpoint("http", url => url.Url = "https://first.example/app");
        var second = builder.AddExecutable("second", "unused", ".").WithHttpEndpoint();
        string? observed = null;
        second.WithUrls(async context =>
        {
            observed = await first.GetEndpoint("http").BrowserOrigins.GetValueAsync(context.CancellationToken);
        });
        foreach (var resource in new[] { first, second })
        {
            var endpoint = resource.GetEndpoint("http").EndpointAnnotation;
            endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
        }
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
 
        await orchestrator.PublishEndpointsAllocatedAsync(
            [new(first.Resource, app.Services), new(second.Resource, app.Services)],
            builder.Eventing, EventDispatchBehavior.BlockingSequential, default).DefaultTimeout();
 
        Assert.Equal("https://first.example,http://localhost:8080", observed);
        Assert.Equal("http://localhost:8080", await second.GetEndpoint("http").BrowserOrigins.GetValueAsync());
    }
 
    [Fact]
    public async Task UrlCallbackScopeDoesNotLeakIntoOutlivingTasks()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var first = builder.AddExecutable("first", "unused", ".").WithHttpEndpoint();
        var second = builder.AddExecutable("second", "unused", ".").WithHttpEndpoint();
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var readStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        Task<string?>? inheritedRead = null;
        first.WithUrls(context =>
        {
            inheritedRead = Task.Run(async () =>
            {
                await release.Task.WaitAsync(context.CancellationToken);
                var read = second.GetEndpoint("http").BrowserOrigins.GetValueAsync(context.CancellationToken).AsTask();
                readStarted.SetResult();
                return await read;
            });
        });
        foreach (var resource in new[] { first, second })
        {
            var endpoint = resource.GetEndpoint("http").EndpointAnnotation;
            endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
        }
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(first.Resource, app.Services)],
            builder.Eventing, EventDispatchBehavior.BlockingSequential, cts.Token).DefaultTimeout();
        release.SetResult();
        await readStarted.Task.DefaultTimeout();
        Assert.NotNull(inheritedRead);
        Assert.False(inheritedRead.IsCompleted);
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(second.Resource, app.Services)],
            builder.Eventing, EventDispatchBehavior.BlockingSequential, cts.Token).DefaultTimeout();
 
        Assert.Equal("http://localhost:8080", await inheritedRead.DefaultTimeout());
    }
 
    [Fact]
    public async Task UrlCallbackFailureDoesNotLeakScopeIntoOrdinaryResolution()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var resource = builder.AddExecutable("resource", "unused", ".").WithHttpEndpoint()
            .WithUrls(_ => throw new InvalidOperationException("Callback failed."));
        var other = builder.AddExecutable("other", "unused", ".").WithHttpEndpoint();
        foreach (var item in new[] { resource, other })
        {
            var endpoint = item.GetEndpoint("http").EndpointAnnotation;
            endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
        }
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(resource.Resource, app.Services)],
            builder.Eventing, EventDispatchBehavior.BlockingSequential, cts.Token).DefaultTimeout();
        var pending = other.GetEndpoint("http").BrowserOrigins.GetValueAsync(cts.Token).AsTask();
        Assert.False(pending.IsCompleted);
        await orchestrator.PublishEndpointsAllocatedAsync([new(other.Resource, app.Services)],
            builder.Eventing, EventDispatchBehavior.BlockingSequential, cts.Token).DefaultTimeout();
 
        Assert.Equal("http://localhost:8080", await pending.DefaultTimeout());
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task CodespacesProjectionFinalizesOriginsBeforeAllocationObservers(bool codespaces)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        builder.Configuration["CODESPACES"] = codespaces.ToString();
        builder.Configuration["CODESPACE_NAME"] = "test-codespace";
        builder.Configuration["GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"] = "app.github.dev";
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint(targetPort: 8080)
            .WithUrlForEndpoint("http", url => url.Url = "/path?x=1#fragment")
            .WithUrlForEndpoint("http", _ => new() { Url = "http://localhost:8080/secondary", DisplayLocation = UrlDisplayLocation.DetailsOnly });
        var endpoint = frontend.GetEndpoint("http");
        endpoint.EndpointAnnotation.AllocatedEndpoint = new(endpoint.EndpointAnnotation, "localhost", 8080);
        var containerAllocation = new AllocatedEndpoint(endpoint.EndpointAnnotation, "frontend.internal", 8080,
            EndpointBindingMode.SingleAddress, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork);
        endpoint.EndpointAnnotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, containerAllocation);
        string? observed = null;
        frontend.OnResourceEndpointsAllocated(async (_, _, ct) =>
        {
            observed = await endpoint.BrowserOrigins.GetValueAsync(ct);
        });
        using var app = builder.Build();
        var origin = codespaces ? "https://test-codespace-8080.app.github.dev" : "http://localhost:8080";
        var origins = codespaces ? $"{origin},http://localhost:8080" : origin;
 
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new(frontend.Resource, app.Services), default);
 
        Assert.Equal(origins, observed);
        Assert.Equal(origins, await endpoint.BrowserOrigins.GetValueAsync(default));
        Assert.Equal("http://localhost:8080", endpoint.Url);
        Assert.Same(containerAllocation, await endpoint.EndpointAnnotation.AllAllocatedEndpoints.GetAllocatedEndpointAsync(KnownNetworkIdentifiers.DefaultAspireContainerNetwork));
        Assert.Equal([$"{origin}/path?x=1#fragment", $"{origin}/secondary"],
            frontend.Resource.Annotations.OfType<ResourceUrlAnnotation>().Select(url => url.Url));
        Assert.True(app.ResourceNotifications.TryGetCurrentState(frontend.Resource.Name, out var state));
        Assert.Equal([$"{origin}/path?x=1#fragment", $"{origin}/secondary"], state.Snapshot.Urls.Select(url => url.Url));
    }
 
    [Fact]
    public async Task CodespacesProjectionPreservesCustomizedPublicOrigins()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        builder.Configuration["CODESPACES"] = "true";
        builder.Configuration["CODESPACE_NAME"] = "test-codespace";
        builder.Configuration["GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"] = "app.github.dev";
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint(targetPort: 8080)
            .WithUrlForEndpoint("http", url => url.Url = "https://custom.example/path?x=1#fragment")
            .WithUrlForEndpoint("http", _ => new() { Url = "http://localhost:8080/secondary", DisplayLocation = UrlDisplayLocation.DetailsOnly });
        var endpoint = frontend.GetEndpoint("http");
        endpoint.EndpointAnnotation.AllocatedEndpoint = new(endpoint.EndpointAnnotation, "localhost", 8080);
        using var app = builder.Build();
 
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new(frontend.Resource, app.Services), default);
 
        Assert.Equal("https://custom.example,https://test-codespace-8080.app.github.dev,http://localhost:8080",
            await endpoint.BrowserOrigins.GetValueAsync(default));
        Assert.Equal(["https://custom.example/path?x=1#fragment", "https://test-codespace-8080.app.github.dev/secondary"],
            frontend.Resource.Annotations.OfType<ResourceUrlAnnotation>().Select(url => url.Url));
    }
 
    [Theory]
    [InlineData(EventDispatchBehavior.BlockingSequential)]
    [InlineData(EventDispatchBehavior.BlockingConcurrent)]
    [InlineData(EventDispatchBehavior.NonBlockingSequential)]
    [InlineData(EventDispatchBehavior.NonBlockingConcurrent)]
    public async Task BrowserUrlIsFinalizedBeforeAllocationEventHandlers(EventDispatchBehavior dispatchBehavior)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var callbackCount = 0;
        var callbackEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var completeCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var observed = new TaskCompletionSource<(bool Finalized, string? Origin)>(TaskCreationOptions.RunContinuationsAsynchronously);
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint()
            .WithUrls(async context =>
            {
                callbackEntered.SetResult();
                await completeCallback.Task.WaitAsync(context.CancellationToken);
                context.Urls[0].Url = "https://frontend.example/app";
                Interlocked.Increment(ref callbackCount);
            });
        var browserEndpoint = frontend.GetEndpoint("http").BrowserOrigins;
        frontend.OnResourceEndpointsAllocated(async (_, _, ct) =>
        {
            var finalized = Volatile.Read(ref callbackCount) == 1;
            var origin = await browserEndpoint.GetValueAsync(ct);
            observed.SetResult((finalized, origin));
        });
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
 
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
 
        var publish = orchestrator.PublishEndpointsAllocatedAsync(
            [new(frontend.Resource, app.Services)], builder.Eventing, dispatchBehavior, cts.Token);
        try
        {
            await callbackEntered.Task.DefaultTimeout();
            Assert.False(publish.IsCompleted);
 
            Assert.False(observed.Task.IsCompleted);
        }
        finally
        {
            completeCallback.SetResult();
        }
        await publish.DefaultTimeout();
 
        Assert.Equal((true, "https://frontend.example,http://localhost:8080"), await observed.Task.DefaultTimeout());
        Assert.Equal(1, callbackCount);
    }
 
    [Theory]
    [InlineData(null, "localhost", null, "http://localhost:8080")]
    [InlineData("http://myapp-apphost.dev.localhost:18888", "localhost", null, "http://frontend-myapp.dev.localhost:8080")]
    [InlineData(null, "custom.localhost", null, "http://custom.localhost:8080")]
    [InlineData(null, "localhost", "/swagger?x=1#top", "http://localhost:8080")]
    [InlineData(null, "localhost", "https://frontend.example:9443/swagger?x=1#top", "https://frontend.example:9443")]
    [InlineData(null, "localhost", "https://frontend.example:443/swagger", "https://frontend.example")]
    [InlineData(null, "localhost", "http://frontend.example:80/swagger", "http://frontend.example")]
    [InlineData(null, "localhost", "http://[::1]:8081/swagger", "http://[::1]:8081")]
    [InlineData(null, "localhost", "https://b\u00fccher.example/app?x=1#top", "https://xn--bcher-kva.example")]
    [InlineData(null, "localhost", "HTTPS://XN--BCHER-KVA.EXAMPLE:443/app", "https://xn--bcher-kva.example")]
    [InlineData(null, "localhost", "https://b\u00fccher.example:9443/app", "https://xn--bcher-kva.example:9443")]
    [InlineData(null, "localhost", "https://[::1]:443/app", "https://[::1]")]
    [InlineData(null, "localhost", "http://127.0.0.1:80/app", "http://127.0.0.1")]
    [InlineData(null, "localhost", "https://reader@frontend.example/app?x=1#top", "https://frontend.example")]
    public async Task BrowserUrlResolvesCustomizedOrigin(string? dashboardUrl, string targetHost, string? customUrl, string expectedOrigin)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint();
        if (customUrl is not null)
        {
            frontend.WithUrlForEndpoint("http", url => url.Url = customUrl);
        }
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.TargetHost = targetHost;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
        var containerAllocation = new AllocatedEndpoint(endpoint, "frontend.dev.internal", 5000,
            EndpointBindingMode.SingleAddress, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork);
        endpoint.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, containerAllocation);
        var browserEndpoint = frontend.GetEndpoint("http").BrowserOrigins;
        var backend = builder.AddContainer("backend", "unused")
            .WithEnvironment("FRONTEND_ORIGIN", browserEndpoint)
            .WithEnvironment("HOST_ENDPOINT", frontend.GetEndpoint("http", KnownNetworkIdentifiers.LocalhostNetwork))
            .WithEnvironment("SERVICE_ENDPOINT", frontend.GetEndpoint("http"));
 
        using var app = builder.Build();
        var notifications = ResourceNotificationServiceTestHelpers.Create();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), notifications,
            applicationEventing: builder.Eventing, dashboardOptions: new() { DashboardUrl = dashboardUrl });
 
        var pendingOrigin = browserEndpoint.GetValueAsync();
        Assert.False(pendingOrigin.IsCompleted);
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
 
        var expectedOrigins = expectedOrigin == "http://localhost:8080"
            ? expectedOrigin : $"{expectedOrigin},http://localhost:8080";
        Assert.Equal(expectedOrigins, await pendingOrigin.DefaultTimeout());
        Assert.Equal(expectedOrigins, await browserEndpoint.GetValueAsync());
        Assert.Equal("http://localhost:8080", frontend.GetEndpoint("http").Url);
        Assert.Same(containerAllocation, await endpoint.AllAllocatedEndpoints.GetAllocatedEndpointAsync(KnownNetworkIdentifiers.DefaultAspireContainerNetwork));
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(backend.Resource, DistributedApplicationOperation.Run, app.Services).DefaultTimeout();
        Assert.Equal(expectedOrigins, environment["FRONTEND_ORIGIN"]);
        Assert.Equal("http://localhost:8080", environment["HOST_ENDPOINT"]);
        Assert.Equal("http://frontend.dev.internal:5000", environment["SERVICE_ENDPOINT"]);
        var primaryUrl = frontend.Resource.Annotations.OfType<ResourceUrlAnnotation>().First();
        if (customUrl is not null)
        {
            Assert.Equal(customUrl.StartsWith('/') ? $"http://localhost:8080{customUrl}" : customUrl, primaryUrl.Url);
        }
        else
        {
            Assert.Equal(expectedOrigin, new Uri(primaryUrl.Url).GetLeftPart(UriPartial.Authority));
        }
    }
 
    [Fact]
    public async Task BrowserUrlsUseDisplayOrderAndExcludeCrossResourceLinks()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var frontend = builder.AddExecutable("frontend", "unused", ".")
            .WithHttpEndpoint()
            .WithUrlForEndpoint("http", url => url.Url = "https://frontend.example/app")
            .WithUrlForEndpoint("http", _ => new() { Url = "https://frontend.example/swagger" })
            .WithUrlForEndpoint("http", _ => new() { Url = "https://admin.example", DisplayOrder = 1000 })
            .WithUrls(context => context.Urls.Reverse());
        var manager = builder.AddExecutable("manager", "unused", ".")
            .WithUrlForEndpoint(frontend.GetEndpoint("http"), url => url.Url = "https://manager.example/admin");
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
 
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
        await orchestrator.PublishEndpointsAllocatedAsync([new(manager.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
 
        Assert.Equal("https://admin.example,https://frontend.example,http://localhost:8080", await frontend.GetEndpoint("http").BrowserOrigins.GetValueAsync());
        Assert.Equal("https://manager.example/admin", Assert.Single(manager.Resource.Annotations.OfType<ResourceUrlAnnotation>()).Url);
    }
 
    [Fact]
    public async Task BrowserUrlWaitsForUrlCallbackCompletion()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var callbackEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var completeCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint()
            .WithUrls(async context =>
            {
                callbackEntered.SetResult();
                await completeCallback.Task;
                context.Urls[0].Url = "https://frontend.example/app";
            });
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
 
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
 
        var pendingOrigin = frontend.GetEndpoint("http").BrowserOrigins.GetValueAsync();
        var publish = orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
        await callbackEntered.Task.DefaultTimeout();
        try
        {
            Assert.False(pendingOrigin.IsCompleted);
        }
        finally
        {
            completeCallback.SetResult();
        }
        await publish.DefaultTimeout();
 
        Assert.Equal("https://frontend.example,http://localhost:8080", await pendingOrigin.DefaultTimeout());
    }
 
    [Fact]
    public async Task BrowserUrlsUseReplacementAnnotations()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint()
            .WithUrls(context =>
            {
                var original = context.Urls[0];
                context.Urls[0] = new ResourceUrlAnnotation
                {
                    Endpoint = original.Endpoint,
                    Url = "https://frontend.example/app",
                    DisplayOrder = 10
                };
            });
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
 
        var origins = frontend.GetEndpoint("http").BrowserOrigins;
        Assert.Equal("https://frontend.example,http://localhost:8080", await origins.GetValueAsync());
        Assert.Equal("http://localhost:8080", frontend.GetEndpoint("http").Url);
    }
 
    [Fact]
    public async Task BrowserUrlCallbackFailureCompletesWaiters()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint();
        var browserEndpoint = frontend.GetEndpoint("http").BrowserOrigins;
        frontend.WithUrls(_ => throw new InvalidOperationException("URL customization failed."));
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
 
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
 
        var pendingOrigin = browserEndpoint.GetValueAsync();
        await orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default).DefaultTimeout();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await pendingOrigin.DefaultTimeout());
        Assert.Equal("URL customization failed.", exception.Message);
        Assert.Equal("http://localhost:8080", frontend.GetEndpoint("http").Url);
    }
 
    [Theory]
    [InlineData("mailto:frontend@example.com")]
    [InlineData("swagger")]
    public async Task InvalidBrowserOriginPreservesDashboardLinks(string invalidUrl)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint()
            .WithUrlForEndpoint("http", url => url.Url = invalidUrl)
            .WithHttpsEndpoint()
            .WithUrlForEndpoint("https", url => url.Url = "https://frontend.example/home");
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
        var httpsEndpoint = frontend.GetEndpoint("https").EndpointAnnotation;
        httpsEndpoint.AllocatedEndpoint = new(httpsEndpoint, "localhost", 8443);
 
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
 
        Assert.Equal("http://localhost:8080", await frontend.GetEndpoint("http").BrowserOrigins.GetValueAsync().DefaultTimeout());
        Assert.Equal("https://frontend.example,https://localhost:8443", await frontend.GetEndpoint("https").BrowserOrigins.GetValueAsync().DefaultTimeout());
        Assert.Equal([invalidUrl, "https://frontend.example/home"],
            frontend.Resource.Annotations.OfType<ResourceUrlAnnotation>().Select(url => url.Url));
    }
 
    [Fact]
    public async Task BrowserOriginsRetainAllocatedEndpointWhenDisplayLinksAreRemoved()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint()
            .WithUrls(context => context.Urls.Clear());
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
 
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
 
        Assert.Equal("http://localhost:8080", await frontend.GetEndpoint("http").BrowserOrigins.GetValueAsync());
        Assert.Empty(frontend.Resource.Annotations.OfType<ResourceUrlAnnotation>());
    }
 
    [Fact]
    public async Task BrowserUrlCanRecoverFromUrlCallbackFailure()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var failCallback = true;
        var frontend = builder.AddExecutable("frontend", "unused", ".").WithHttpEndpoint()
            .WithUrls(context =>
            {
                if (failCallback)
                {
                    throw new InvalidOperationException("URL customization failed.");
                }
                context.Urls[0].Url = "https://frontend.example/app";
            });
        var endpoint = frontend.GetEndpoint("http").EndpointAnnotation;
        endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8080);
        var browserEndpoint = frontend.GetEndpoint("http").BrowserOrigins;
 
        using var app = builder.Build();
        var orchestrator = CreateOrchestrator(app.Services.GetRequiredService<DistributedApplicationModel>(), ResourceNotificationServiceTestHelpers.Create(),
            applicationEventing: builder.Eventing);
 
        await orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
        await Assert.ThrowsAsync<InvalidOperationException>(async () => await browserEndpoint.GetValueAsync());
 
        failCallback = false;
        await orchestrator.PublishEndpointsAllocatedAsync([new(frontend.Resource, app.Services)], builder.Eventing, EventDispatchBehavior.BlockingSequential, default);
 
        Assert.Equal("https://frontend.example,http://localhost:8080", await browserEndpoint.GetValueAsync());
    }
 
    [Theory]
    [InlineData("Waiting")]
    [InlineData("Starting")]
    [InlineData("Running")]
    public async Task ResourceStoppedEventIsNotRepeatedWhenHandlerChangesSnapshotState(string snapshotState)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var resource = builder.AddExecutable("resource", "unused", ".");
        var events = new DcpExecutorEvents();
        var notifications = ResourceNotificationServiceTestHelpers.Create();
        var stoppedCount = 0;
 
        resource.OnResourceStopped(async (_, @event, _) =>
        {
            Interlocked.Increment(ref stoppedCount);
            Assert.Equal(KnownResourceStates.Finished, @event.ResourceEvent.Snapshot.State?.Text);
            Assert.Equal(0, @event.ResourceEvent.Snapshot.ExitCode);
            await notifications.PublishUpdateAsync(resource.Resource, resource.Resource.Name,
                snapshot => snapshot with { State = snapshotState });
        });
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var orchestrator = CreateOrchestrator(model, notificationService: notifications, dcpEvents: events, applicationEventing: builder.Eventing);
        await orchestrator.RunApplicationAsync();
 
        await PublishStateAsync(null, null);
        await PublishStateAsync(KnownResourceStates.Running, null);
        await PublishStateAsync(KnownResourceStates.Finished, KnownResourceStates.Running);
        Assert.Equal(1, stoppedCount);
 
        await PublishStateAsync(KnownResourceStates.Finished, KnownResourceStates.Finished);
        Assert.Equal(1, stoppedCount);
 
        await PublishStateAsync(KnownResourceStates.Exited, KnownResourceStates.Finished);
        await PublishStateAsync(KnownResourceStates.FailedToStart, KnownResourceStates.Exited);
        Assert.Equal(1, stoppedCount);
 
        await PublishStateAsync(KnownResourceStates.Running, KnownResourceStates.FailedToStart);
        await PublishStateAsync(KnownResourceStates.Finished, KnownResourceStates.Running);
        Assert.Equal(2, stoppedCount);
 
        Task PublishStateAsync(string? state, string? previousState) => events.PublishAsync(new OnResourceChangedContext(
            CancellationToken.None, KnownResourceTypes.Executable, resource.Resource, resource.Resource.Name,
            new ResourceStatus(state, null, null), previousState,
            snapshot => snapshot with { State = state is not null ? new(state, null) : snapshot.State, ExitCode = state == KnownResourceStates.Finished ? 0 : null }));
    }
 
    [Fact]
    public async Task ParentPropertySetOnChildResource()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parentResource = builder.AddContainer("database", "image");
        var childResource = builder.AddResource(new CustomChildResource("child", parentResource.Resource));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        string? parentResourceId = null;
        string? childParentResourceId = null;
        var watchResourceTask = Task.Run(async () =>
        {
            await foreach (var item in resourceNotificationService.WatchAsync())
            {
                if (item.Resource == parentResource.Resource)
                {
                    parentResourceId = item.ResourceId;
                }
                else if (item.Resource == childResource.Resource)
                {
                    childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
 
                if (parentResourceId != null && childParentResourceId != null)
                {
                    return;
                }
            }
        });
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await watchResourceTask.DefaultTimeout();
 
        Assert.Equal(parentResourceId, childParentResourceId);
    }
 
    [Fact]
    public async Task ParentAnnotationOnChildResource()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parentResource = builder.AddResource(new CustomResource("parent"));
        var childResource = builder.AddResource(new CustomResource("child"))
            .WithParentRelationship(parentResource);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        string? parentResourceId = null;
        string? childParentResourceId = null;
        var watchResourceTask = Task.Run(async () =>
        {
            await foreach (var item in resourceNotificationService.WatchAsync())
            {
                if (item.Resource == parentResource.Resource)
                {
                    parentResourceId = item.ResourceId;
                }
                else if (item.Resource == childResource.Resource)
                {
                    childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
 
                if (parentResourceId != null && childParentResourceId != null)
                {
                    return;
                }
            }
        });
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await watchResourceTask.DefaultTimeout();
 
        Assert.Equal(parentResourceId, childParentResourceId);
    }
 
    [Fact]
    public async Task InitializeResourceEventPublished()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var resource = builder.AddResource(new CustomResource("resource"));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
        var applicationEventing = builder.Eventing;
 
        var initResourceTcs = new TaskCompletionSource();
        InitializeResourceEvent? initEvent = null;
        resource.OnInitializeResource((_, @event, _) =>
        {
            initEvent = @event;
            initResourceTcs.SetResult();
            return Task.CompletedTask;
        });
 
        applicationEventing.Subscribe<InitializeResourceEvent>(resource.Resource, (@event, ct) =>
        {
            initEvent = @event;
            initResourceTcs.SetResult();
            return Task.CompletedTask;
        });
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: applicationEventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await initResourceTcs.Task; //.DefaultTimeout();
 
        Assert.True(initResourceTcs.Task.IsCompletedSuccessfully);
        Assert.NotNull(initEvent);
        Assert.NotNull(initEvent.Logger);
        Assert.NotNull(initEvent.Services);
        Assert.Equal(resource.Resource, initEvent.Resource);
        Assert.Equal(resourceNotificationService, initEvent.Notifications);
        Assert.Equal(applicationEventing, initEvent.Eventing);
    }
 
    [Fact]
    public async Task WithParentRelationshipSetsParentPropertyCorrectly()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parent = builder.AddContainer("parent", "image");
        var child = builder.AddContainer("child", "image").WithParentRelationship(parent);
        var child2 = builder.AddContainer("child2", "image").WithParentRelationship(parent);
 
        var nestedChild = builder.AddContainer("nested-child", "image").WithParentRelationship(child);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        string? parentResourceId = null;
        string? childResourceId = null;
        string? childParentResourceId = null;
        string? child2ParentResourceId = null;
        string? nestedChildParentResourceId = null;
        var watchResourceTask = Task.Run(async () =>
        {
            await foreach (var item in resourceNotificationService.WatchAsync())
            {
                if (item.Resource == parent.Resource)
                {
                    parentResourceId = item.ResourceId;
                }
                else if (item.Resource == child.Resource)
                {
                    childResourceId = item.ResourceId;
                    childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
                else if (item.Resource == nestedChild.Resource)
                {
                    nestedChildParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
                else if (item.Resource == child2.Resource)
                {
                    child2ParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
 
                if (parentResourceId != null && childParentResourceId != null && nestedChildParentResourceId != null && child2ParentResourceId != null)
                {
                    return;
                }
            }
        });
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await watchResourceTask.DefaultTimeout();
 
        Assert.Equal(parentResourceId, childParentResourceId);
        Assert.Equal(parentResourceId, child2ParentResourceId);
 
        // Nested child should be parented on the direct parent
        Assert.Equal(childResourceId, nestedChildParentResourceId);
    }
 
    [Fact]
    public async Task LastWithParentRelationshipWins()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var firstParent = builder.AddContainer("firstParent", "image");
        var secondParent = builder.AddContainer("secondParent", "image");
 
        var child = builder.AddContainer("child", "image");
 
        child.WithParentRelationship(firstParent);
        child.WithParentRelationship(secondParent);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        string? firstParentResourceId = null;
        string? secondParentResourceId = null;
        string? childParentResourceId = null;
        var watchResourceTask = Task.Run(async () =>
        {
            await foreach (var item in resourceNotificationService.WatchAsync())
            {
                if (item.Resource == firstParent.Resource)
                {
                    firstParentResourceId = item.ResourceId;
                }
                else if (item.Resource == secondParent.Resource)
                {
                    secondParentResourceId = item.ResourceId;
                }
                else if (item.Resource == child.Resource)
                {
                    childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
 
                if (firstParentResourceId != null && secondParentResourceId != null && childParentResourceId != null)
                {
                    return;
                }
            }
        });
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await watchResourceTask.DefaultTimeout();
 
        // child should be parented to the last parent set
        Assert.Equal(secondParentResourceId, childParentResourceId);
    }
 
    [Fact]
    public async Task WithParentRelationshipWorksWithProjects()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var projectA = builder.AddProject<ProjectA>("projecta");
        var projectB = builder.AddProject<ProjectB>("projectb").WithParentRelationship(projectA);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        string? projectAResourceId = null;
        string? projectBParentResourceId = null;
        var watchResourceTask = Task.Run(async () =>
        {
            await foreach (var item in resourceNotificationService.WatchAsync())
            {
                if (item.Resource == projectA.Resource)
                {
                    projectAResourceId = item.ResourceId;
                }
                else if (item.Resource == projectB.Resource)
                {
                    projectBParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
 
                if (projectAResourceId != null && projectBParentResourceId != null)
                {
                    return;
                }
            }
        });
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await watchResourceTask.DefaultTimeout();
 
        Assert.Equal(projectAResourceId, projectBParentResourceId);
    }
 
    [Fact]
    public void DetectsCircularDependency()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var container1 = builder.AddContainer("container1", "image");
        var container2 = builder.AddContainer("container2", "image2");
        var container3 = builder.AddContainer("container3", "image3");
 
        container1.WithParentRelationship(container2);
        container2.WithParentRelationship(container3);
        container3.WithParentRelationship(container1);
 
        using var app = builder.Build();
 
        var e = Assert.Throws<InvalidOperationException>(() => app.Services.GetService<ApplicationOrchestrator>());
        Assert.Contains("Circular dependency detected", e.Message);
    }
 
    [Fact]
    public async Task GrandChildResourceWithConnectionString()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parentResource = builder.AddResource(new ParentResourceWithConnectionString("parent"));
        var childResource = builder.AddResource(
            new ChildResourceWithConnectionString("child", new Dictionary<string, string> { { "Namespace", "ns" } }, parentResource.Resource)
        );
        var grandChildResource = builder.AddResource(
            new ChildResourceWithConnectionString("grand-child", new Dictionary<string, string> { { "Database", "db" } }, childResource.Resource)
        );
 
        await using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
        var applicationEventing = new DistributedApplicationEventing();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: applicationEventing);
        await appOrchestrator.RunApplicationAsync();
 
        bool parentConnectionStringAvailable = false;
        bool childConnectionStringAvailable = false;
        bool grandChildConnectionStringAvailable = false;
 
        applicationEventing.Subscribe<ConnectionStringAvailableEvent>(parentResource.Resource, (_, _) =>
        {
            parentConnectionStringAvailable = true;
            return Task.CompletedTask;
        });
        applicationEventing.Subscribe<ConnectionStringAvailableEvent>(childResource.Resource, (_, _) =>
        {
            childConnectionStringAvailable = true;
            return Task.CompletedTask;
        });
        applicationEventing.Subscribe<ConnectionStringAvailableEvent>(grandChildResource.Resource, (_, _) =>
        {
            grandChildConnectionStringAvailable = true;
            return Task.CompletedTask;
        });
 
        await events.PublishAsync(new OnConnectionStringAvailableContext(CancellationToken.None, parentResource.Resource));
 
        Assert.True(parentConnectionStringAvailable);
        Assert.True(childConnectionStringAvailable);
        Assert.True(grandChildConnectionStringAvailable);
    }
 
    [Fact]
    public async Task ConnectionStringAvailableEventPublishesBeforeBeforeResourceStartedEvent()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var resource = builder.AddResource(new TestResourceWithConnectionString("test-resource", "Server=localhost:5432;Database=testdb"));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
        var applicationEventing = new DistributedApplicationEventing();
        var observedEvents = new List<string>();
 
        applicationEventing.Subscribe<ConnectionStringAvailableEvent>(resource.Resource, (_, _) =>
        {
            observedEvents.Add(nameof(ConnectionStringAvailableEvent));
            return Task.CompletedTask;
        });
        applicationEventing.Subscribe<BeforeResourceStartedEvent>(resource.Resource, (_, _) =>
        {
            observedEvents.Add(nameof(BeforeResourceStartedEvent));
            return Task.CompletedTask;
        });
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: applicationEventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnConnectionStringAvailableContext(CancellationToken.None, resource.Resource));
        await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Executable, resource.Resource, "test-resource-dcp"));
 
        Assert.Collection(
            observedEvents,
            eventName => Assert.Equal(nameof(ConnectionStringAvailableEvent), eventName),
            eventName => Assert.Equal(nameof(BeforeResourceStartedEvent), eventName));
    }
 
    [Fact]
    public async Task ConnectionStringAvailableEventUpdatesConnectionStringAndResolvablePropertiesWithoutDuplicates()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var unresolvedProperty = ReferenceExpression.Create($"{new ThrowingValueProvider()}");
        var resource = builder.AddResource(new TestResourceWithConnectionString("test-resource", "Server=localhost:5432;Database=testdb", "testdb", "localhost", unresolvedProperty));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
        var applicationEventing = new DistributedApplicationEventing();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: applicationEventing);
        await appOrchestrator.RunApplicationAsync();
 
        await applicationEventing.PublishAsync(new ConnectionStringAvailableEvent(resource.Resource, app.Services), CancellationToken.None);
        await applicationEventing.PublishAsync(new ConnectionStringAvailableEvent(resource.Resource, app.Services), CancellationToken.None);
 
        Assert.True(resourceNotificationService.TryGetCurrentState(resource.Resource.Name, out var currentState));
        Assert.Collection(
            currentState.Snapshot.Properties,
            connectionStringProperty =>
            {
                Assert.Equal(KnownProperties.Resource.ConnectionString, connectionStringProperty.Name);
                Assert.Equal("Server=localhost:5432;Database=testdb", connectionStringProperty.Value);
                Assert.True(connectionStringProperty.IsSensitive);
            },
            connectionPropertiesProperty =>
            {
                Assert.Equal(KnownProperties.Resource.ConnectionProperties, connectionPropertiesProperty.Name);
                Assert.Equal(
                    new Dictionary<string, string?>
                    {
                        ["DatabaseName"] = "testdb",
                        ["Host"] = "localhost"
                    },
                    Assert.IsAssignableFrom<IReadOnlyDictionary<string, string?>>(connectionPropertiesProperty.Value));
                Assert.True(connectionPropertiesProperty.IsSensitive);
            });
    }
 
    [Fact]
    public async Task OnResourceFailedToStart_WithErrorMessage_SetsErrorStyleOnState()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var container = builder.AddContainer("api", "test-image");
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourceFailedToStartContext(
            CancellationToken.None,
            KnownResourceTypes.Container,
            container.Resource,
            "api-dcp",
            ErrorMessage: "The endpoint `https` is not defined for the resource `api`. Available endpoints: `http`."));
 
        Assert.True(resourceNotificationService.TryGetCurrentState("api-dcp", out var snapshotEvent));
        Assert.Equal(KnownResourceStates.FailedToStart, snapshotEvent.Snapshot.State?.Text);
        Assert.Equal(KnownResourceStateStyles.Error, snapshotEvent.Snapshot.State?.Style);
    }
 
    [Fact]
    public async Task OnResourceFailedToStart_WithoutErrorMessage_DoesNotSetErrorStyle()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var container = builder.AddContainer("api", "test-image");
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourceFailedToStartContext(
            CancellationToken.None,
            KnownResourceTypes.Container,
            container.Resource,
            "api-dcp"));
 
        Assert.True(resourceNotificationService.TryGetCurrentState("api-dcp", out var snapshotEvent));
        Assert.Equal(KnownResourceStates.FailedToStart, snapshotEvent.Snapshot.State?.Text);
        Assert.Null(snapshotEvent.Snapshot.State?.Style);
    }
 
    [Fact]
    public async Task OnResourceStarting_ToolResourceType_TransitionsToStarting()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var resource = builder.AddResource(new CustomResource("tool"));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Tool, resource.Resource, "tool-dcp"));
 
        Assert.True(resourceNotificationService.TryGetCurrentState("tool-dcp", out var snapshotEvent));
        Assert.Equal(KnownResourceStates.Starting, snapshotEvent.Snapshot.State?.Text);
    }
 
    [Fact]
    public async Task SelfDrivenResourceWaitsForDependencyBeforeStarting()
    {
        // Custom resources that drive their own startup publish BeforeResourceStartedEvent from
        // OnInitializeResource, so they are still in the initial "NotStarted" state when the wait is
        // published rather than having been moved to "Starting" by the orchestrator first.
        // https://github.com/microsoft/aspire/issues/17453
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var blockerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var blocker = AddSelfDrivenResource(builder, "blocker", blockerReleased.Task);
        var waiter = AddSelfDrivenResource(builder, "waiter").WaitFor(blocker);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Waiting, TestContext.Current.CancellationToken).DefaultTimeout();
 
        // The blocker is still inside its initialize callback, so the waiter must not have started.
        Assert.True(resourceNotificationService.TryGetCurrentState(waiter.Resource.Name, out var waitingEvent));
        Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text);
 
        blockerReleased.SetResult();
 
        await resourceNotificationService.WaitForResourceAsync(blocker.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
        await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
    }
 
    [Fact]
    public async Task ResourceWaitingOnSelfDrivenResourceWaitsForItToStart()
    {
        // The reciprocal of SelfDrivenResourceWaitsForDependencyBeforeStarting: the dependency is the resource
        // that drives its own startup, while the waiter is a regular DCP-managed resource that the orchestrator
        // starts through OnResourceStarting. https://github.com/microsoft/aspire/issues/17453
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
        var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        // Stand in for DCP starting the resource. This does not complete until the wait for dependencies is over,
        // so it cannot be awaited here. The resource name is used as the DCP name so that the per-instance update
        // published by OnResourceStarting and the model-level waiting update address the same snapshot.
        var startingTask = events.PublishAsync(new OnResourceStartingContext(
            CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, waiter.Resource.Name));
 
        await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Waiting, TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.True(resourceNotificationService.TryGetCurrentState(dependency.Resource.Name, out var dependencyEvent));
        Assert.NotEqual(KnownResourceStates.Running, dependencyEvent.Snapshot.State?.Text);
 
        dependencyReleased.SetResult();
 
        await resourceNotificationService.WaitForResourceAsync(dependency.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
        await startingTask.DefaultTimeout();
    }
 
    [Fact]
    public async Task WaitIsNotReleasedByAnotherReplicaLeavingWaitingState()
    {
        // The wait is released when the resource is forced out of "Waiting" by the start command, but
        // WaitForResourceAsync only filters on the model resource name, so the release signal has to be scoped
        // to the replica that was forced out. Here the model-level waiting update also re-publishes the running
        // sibling replica, and that must not be mistaken for a force-start of the waiting replica.
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
        var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency);
        waiter.Resource.Annotations.Add(new DcpInstancesAnnotation([
            new DcpInstance("waiter-abc123", "abc123", 0),
            new DcpInstance("waiter-def456", "def456", 1)
        ]));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        // The second replica is already running and is not part of this start attempt.
        await resourceNotificationService.PublishUpdateAsync(waiter.Resource, "waiter-def456", s => s with
        {
            State = KnownResourceStates.Running
        }).DefaultTimeout();
 
        var startingTask = events.PublishAsync(new OnResourceStartingContext(
            CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-abc123"));
 
        await resourceNotificationService.WaitForResourceAsync(
            waiter.Resource.Name,
            e => e.ResourceId == "waiter-abc123" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        // The rebuild command moves non-waiting replicas to "Building" while deliberately leaving waiting
        // replicas alone, so this is a state change a waiting replica really does observe from a sibling.
        await resourceNotificationService.PublishUpdateAsync(waiter.Resource, "waiter-def456", s => s with
        {
            State = KnownResourceStates.Building
        }).DefaultTimeout();
 
        // Negative assertion: give the wait a chance to be released incorrectly. This can only ever produce a
        // false pass, never a false failure - with a model-wide release flag the wait is abandoned immediately.
        await Task.Delay(TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
        Assert.False(startingTask.IsCompleted, "The dependency wait must not be released by a sibling replica leaving the waiting state.");
 
        dependencyReleased.SetResult();
 
        await startingTask.DefaultTimeout();
    }
 
    [Fact]
    public async Task WaitingUpdateDoesNotDisturbNotStartedReplicas()
    {
        // A replicated resource that uses WithExplicitStart is started one replica at a time, so only the replica
        // named in the start request is moved to "Starting". The waiting update, however, is published as a
        // model-level update that reaches every replica, so a sibling that is still "NotStarted" - deliberately not
        // started - must be left alone.
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
        var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency).WithExplicitStart();
        waiter.Resource.Annotations.Add(new DcpInstancesAnnotation([
            new DcpInstance("waiter-abc123", "abc123", 0),
            new DcpInstance("waiter-def456", "def456", 1)
        ]));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        // Both replicas of an explicit start resource report "NotStarted" until someone starts them.
        await PublishReplicaStatesAsync(resourceNotificationService, waiter.Resource, KnownResourceStates.NotStarted);
 
        // Only the first replica is being started.
        var startingTask = events.PublishAsync(new OnResourceStartingContext(
            CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-abc123"));
 
        await resourceNotificationService.WaitForResourceAsync(
            waiter.Resource.Name,
            e => e.ResourceId == "waiter-abc123" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.True(resourceNotificationService.TryGetCurrentState("waiter-def456", out var siblingEvent));
        Assert.Equal(KnownResourceStates.NotStarted, siblingEvent.Snapshot.State?.Text);
 
        dependencyReleased.SetResult();
 
        await startingTask.DefaultTimeout();
    }
 
    [Fact]
    public async Task StartingNotStartedReplicaIsForwardedToDcpWhileSiblingWaits()
    {
        // The consequence of WaitingUpdateDoesNotDisturbNotStartedReplicas: StartResourceAsync reads "Waiting" as
        // proof that startup is already in flight and only updates the snapshot, skipping DCP. If the waiting update
        // relabelled the untouched replica, its start command would silently do nothing - the dashboard would show
        // "Starting" while no process was ever launched.
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
        var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency).WithExplicitStart();
        waiter.Resource.Annotations.Add(new DcpInstancesAnnotation([
            new DcpInstance("waiter-abc123", "abc123", 0),
            new DcpInstance("waiter-def456", "def456", 1)
        ]));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var dcpExecutor = new TestDcpExecutor();
        dcpExecutor.AddResource(waiter.Resource, "waiter-abc123");
        dcpExecutor.AddResource(waiter.Resource, "waiter-def456");
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing, dcpExecutor: dcpExecutor);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await PublishReplicaStatesAsync(resourceNotificationService, waiter.Resource, KnownResourceStates.NotStarted);
 
        var startingTask = events.PublishAsync(new OnResourceStartingContext(
            CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-abc123"));
 
        await resourceNotificationService.WaitForResourceAsync(
            waiter.Resource.Name,
            e => e.ResourceId == "waiter-abc123" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        // Stand in for the user invoking the start command on the replica that was never started.
        await appOrchestrator.StartResourceAsync("waiter-def456", TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.Equal(["waiter-def456"], dcpExecutor.StartedResources);
 
        dependencyReleased.SetResult();
 
        await startingTask.DefaultTimeout();
    }
 
    [Fact]
    public async Task ForceStartingOneReplicaDoesNotReleaseAnotherReplicasWait()
    {
        // Each individually started replica gets its own BeforeResourceStartedEvent, but WaitForResourceAsync
        // filters only on the model resource name, so every one of those waits is fed events for every replica.
        // Forcing replica B out of "Waiting" with the start command must not release replica A's wait - the user
        // only asked for B, and A would otherwise start with unmet dependencies.
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
        var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency);
        waiter.Resource.Annotations.Add(new DcpInstancesAnnotation([
            new DcpInstance("waiter-abc123", "abc123", 0),
            new DcpInstance("waiter-def456", "def456", 1)
        ]));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var dcpExecutor = new TestDcpExecutor();
        dcpExecutor.AddResource(waiter.Resource, "waiter-abc123");
        dcpExecutor.AddResource(waiter.Resource, "waiter-def456");
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing, dcpExecutor: dcpExecutor);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await PublishReplicaStatesAsync(resourceNotificationService, waiter.Resource, KnownResourceStates.NotStarted);
 
        // Both replicas are started, so both end up waiting on the same dependency.
        var startingFirstReplicaTask = events.PublishAsync(new OnResourceStartingContext(
            CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-abc123"));
 
        await resourceNotificationService.WaitForResourceAsync(
            waiter.Resource.Name,
            e => e.ResourceId == "waiter-abc123" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        var startingSecondReplicaTask = events.PublishAsync(new OnResourceStartingContext(
            CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-def456"));
 
        await resourceNotificationService.WaitForResourceAsync(
            waiter.Resource.Name,
            e => e.ResourceId == "waiter-def456" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        // Stand in for the user invoking the start command on the second replica to force it past the wait.
        await appOrchestrator.StartResourceAsync("waiter-def456", TestContext.Current.CancellationToken).DefaultTimeout();
 
        // The replica was already waiting, so the start was served by moving it to "Starting" rather than by DCP.
        Assert.Empty(dcpExecutor.StartedResources);
 
        await startingSecondReplicaTask.DefaultTimeout();
 
        // Negative assertion: give the first replica's wait a chance to be released incorrectly. This can only
        // ever produce a false pass, never a false failure - with a model-wide release flag the wait is abandoned
        // as soon as the second replica leaves "Waiting".
        await Task.Delay(TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
        Assert.False(startingFirstReplicaTask.IsCompleted, "Force-starting one replica must not release another replica's dependency wait.");
 
        dependencyReleased.SetResult();
 
        await startingFirstReplicaTask.DefaultTimeout();
    }
 
    [Fact]
    public async Task ResourceForcedOutOfWaitingStopsWaitingForItsDependency()
    {
        // The Start command forces a waiting resource to start by moving it from "Waiting" to "Starting"
        // (see ApplicationOrchestrator.StartResourceAsync). BeforeResourceStartedEvent has to stop waiting
        // when that happens, even though the dependency is still not ready.
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var blockerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var blocker = AddSelfDrivenResource(builder, "blocker", blockerReleased.Task);
        var waiter = AddSelfDrivenResource(builder, "waiter").WaitFor(blocker);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Waiting, TestContext.Current.CancellationToken).DefaultTimeout();
 
        // Stand in for the Start command.
        await resourceNotificationService.PublishUpdateAsync(waiter.Resource, s => s with { State = KnownResourceStates.Starting }).DefaultTimeout();
 
        await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.True(resourceNotificationService.TryGetCurrentState(blocker.Resource.Name, out var blockerEvent));
        Assert.NotEqual(KnownResourceStates.Running, blockerEvent.Snapshot.State?.Text);
 
        blockerReleased.SetResult();
    }
 
    [Fact]
    public async Task ResourceForcedOutOfWaitingImmediatelyStopsWaitingForItsDependency()
    {
        // Regression test for a race between publishing "Waiting" and subscribing to state changes.
        // WaitForInBeforeResourceStartedEvent detects a force-start by observing the resource leave "Waiting",
        // and ResourceNotificationService.WatchAsync only replays the latest snapshot of each resource rather
        // than the full history. If the Start command lands in the window between the wait publishing "Waiting"
        // and the orchestrator subscribing, a replay-only view shows "Starting" without ever showing "Waiting",
        // and the force-start signal is lost - the resource stays blocked on a dependency that never becomes
        // ready. https://github.com/microsoft/aspire/pull/18930#discussion_r3677777200
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var blockerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var blocker = AddSelfDrivenResource(builder, "blocker", blockerReleased.Task);
        var waiter = AddSelfDrivenResource(builder, "waiter").WaitFor(blocker);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
        await appOrchestrator.RunApplicationAsync();
 
        // Stand in for the Start command, firing as soon as the waiting state is stored. PublishUpdateAsync
        // stores the snapshot synchronously, so a tight poll observes "Waiting" well before any watcher started
        // afterwards could - which is exactly the window the fix has to cover. A delay-based poll or a watcher
        // subscription would only see "Waiting" after the orchestrator had already subscribed, so the regression
        // would go unnoticed. Losing the poll only ever produces a false pass, never a false failure.
        var cancellationToken = TestContext.Current.CancellationToken;
        var forceStartTask = Task.Run(async () =>
        {
            // Wait for the waiter's initial snapshot first so the tight poll below only spins across the short
            // window in which the dependency wait is set up, rather than for the whole of application startup.
            // "Waiting" is tolerated as well: if the poll is late the snapshot has already moved on, and the
            // loop below exits immediately.
            await resourceNotificationService.WaitForResourceAsync(
                waiter.Resource.Name,
                [KnownResourceStates.NotStarted, KnownResourceStates.Waiting],
                cancellationToken);
 
            while (!resourceNotificationService.TryGetCurrentState(waiter.Resource.Name, out var waiterEvent)
                || waiterEvent.Snapshot.State?.Text != KnownResourceStates.Waiting)
            {
                cancellationToken.ThrowIfCancellationRequested();
                await Task.Yield();
            }
 
            await resourceNotificationService.PublishUpdateAsync(waiter.Resource, s => s with { State = KnownResourceStates.Starting });
        }, cancellationToken);
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await forceStartTask.DefaultTimeout();
 
        // The waiter has to start even though the blocker is still inside its initialize callback.
        await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Running, cancellationToken).DefaultTimeout();
 
        Assert.True(resourceNotificationService.TryGetCurrentState(blocker.Resource.Name, out var blockerEvent));
        Assert.NotEqual(KnownResourceStates.Running, blockerEvent.Snapshot.State?.Text);
 
        blockerReleased.SetResult();
    }
 
    [Fact]
    public async Task ConnectionStringResourceWaitsForReferencedResourceBeforeBecomingAvailable()
    {
        // AddConnectionString has the same shape as a third-party custom resource: it starts in "NotStarted"
        // and publishes BeforeResourceStartedEvent from OnInitializeResource, and it implicitly adds a
        // WaitForStart on every resource its expression references. It must not report a connection string
        // before those resources have started. https://github.com/microsoft/aspire/issues/17453
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var referencedReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var referenced = AddSelfDrivenResource(builder, "referenced", referencedReleased.Task);
        var connectionString = builder.AddConnectionString("cs", ReferenceExpression.Create($"Endpoint={referenced.Resource}"));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await resourceNotificationService.WaitForResourceAsync(connectionString.Resource.Name, KnownResourceStates.Waiting, TestContext.Current.CancellationToken).DefaultTimeout();
 
        referencedReleased.SetResult();
 
        await resourceNotificationService.WaitForResourceAsync(referenced.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
        await resourceNotificationService.WaitForResourceAsync(connectionString.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
    }
 
    [Fact]
    public async Task ExplicitStartResourceWithoutDcpInstancesReportsWaitingForItsDependency()
    {
        // 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 builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var blockerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var blocker = AddSelfDrivenResource(builder, "blocker", blockerReleased.Task);
        var waiter = builder.AddConnectionString("waiter", ReferenceExpression.Create($"Host=localhost;Port=5678"))
            .WaitFor(blocker)
            .WithExplicitStart();
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
        await appOrchestrator.RunApplicationAsync();
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        var waitingEvent = await resourceNotificationService.WaitForResourceAsync(
            waiter.Resource.Name,
            re => re.Snapshot.State?.Text == KnownResourceStates.Waiting,
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        var waitingFor = waitingEvent.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.WaitingFor)?.Value;
        Assert.Equal(new[] { blocker.Resource.Name }, Assert.IsAssignableFrom<IEnumerable<string>>(waitingFor));
 
        blockerReleased.SetResult();
 
        // The resource starts without anyone issuing a start command, which is what makes "Waiting" - rather than
        // "NotStarted" - the honest label for the period above.
        await resourceNotificationService.WaitForResourceAsync(blocker.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
        await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
    }
 
    /// <summary>
    /// Publishes <paramref name="state"/> for every DCP instance (replica) of <paramref name="resource"/>, as an
    /// individual per-instance update rather than a model-level one.
    /// </summary>
    private static async Task PublishReplicaStatesAsync(ResourceNotificationService notificationService, IResource resource, string state)
    {
        Assert.True(resource.TryGetInstances(out var instances));
 
        foreach (var instance in instances)
        {
            await notificationService.PublishUpdateAsync(resource, instance.Name, s => s with { State = state }).DefaultTimeout();
        }
    }
 
    /// <summary>
    /// Adds a resource shaped like a custom hosting integration that drives its own startup: it starts in
    /// <see cref="KnownResourceStates.NotStarted"/> and publishes <see cref="BeforeResourceStartedEvent"/> itself
    /// instead of being started by DCP.
    /// </summary>
    private static IResourceBuilder<CustomResourceWithWaitSupport> AddSelfDrivenResource(IDistributedApplicationBuilder builder, string name, Task? gate = null)
    {
        return builder.AddResource(new CustomResourceWithWaitSupport(name))
            .WithInitialState(new CustomResourceSnapshot
            {
                ResourceType = "CustomResource",
                State = KnownResourceStates.NotStarted,
                Properties = []
            })
            .OnInitializeResource(async (resource, @event, ct) =>
            {
                // This is where waiting happens.
                await @event.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, @event.Services), ct);
 
                if (gate is not null)
                {
                    await gate;
                }
 
                // ResourceHealthCheckService isn't running in this harness, so stand in for it and publish the
                // ready event a resource with no health checks would otherwise get. Without it, WaitFor - which
                // waits until healthy - could never observe the dependency as ready.
                await @event.Notifications.PublishUpdateAsync(resource, s => s with
                {
                    State = KnownResourceStates.Running,
                    ResourceReadyEvent = new EventSnapshot(Task.CompletedTask)
                });
            });
    }
 
    private ApplicationOrchestrator CreateOrchestrator(
        DistributedApplicationModel distributedAppModel,
        ResourceNotificationService notificationService,
        DcpExecutorEvents? dcpEvents = null,
        IDistributedApplicationEventing? applicationEventing = null,
        ResourceLoggerService? resourceLoggerService = null,
        DashboardOptions? dashboardOptions = null,
        TestDcpExecutor? dcpExecutor = null)
    {
        var services = new ServiceCollection();
        var configuration = new ConfigurationManager();
        services.AddTestAndResourceLogging(testOutputHelper, configuration);
        var serviceProvider = services.BuildServiceProvider();
        resourceLoggerService ??= new ResourceLoggerService();
 
        var executionContext = new DistributedApplicationExecutionContext(
            new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = serviceProvider });
 
        return new ApplicationOrchestrator(
            distributedAppModel,
            dcpExecutor ?? new TestDcpExecutor(),
            dcpEvents ?? new DcpExecutorEvents(),
            [],
            notificationService,
            resourceLoggerService,
            applicationEventing ?? new DistributedApplicationEventing(),
            serviceProvider,
            executionContext,
            new ParameterProcessor(
                notificationService,
                resourceLoggerService,
                CreateInteractionService(),
                serviceProvider.GetRequiredService<ILogger<ParameterProcessor>>(),
                executionContext,
                deploymentStateManager: new MockDeploymentStateManager(),
                userSecretsManager: UserSecrets.NoopUserSecretsManager.Instance),
            Options.Create(dashboardOptions ?? new()),
            serviceProvider.GetRequiredService<ILogger<ApplicationOrchestrator>>()
        );
    }
 
    private static InteractionService CreateInteractionService(DistributedApplicationOptions? options = null)
    {
        return new InteractionService(
            NullLogger<InteractionService>.Instance,
            options ?? new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
            new TestInteractionFileUploadStore(),
            new InteractionServiceConsumerState());
    }
 
    private sealed class MockDeploymentStateManager : IDeploymentStateManager
    {
        public string? StateFilePath => null;
 
        public Task<DeploymentStateSection> AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default)
        {
            return Task.FromResult(new DeploymentStateSection(sectionName, [], 0));
        }
 
        public Task<DeploymentStateSection> AcquireCurrentSectionAsync(string sectionName, CancellationToken cancellationToken = default)
            => AcquireSectionAsync(sectionName, cancellationToken);
 
        public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default)
        {
            return Task.CompletedTask;
        }
 
        public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
 
        public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default)
        {
            return Task.CompletedTask;
        }
    }
 
    private sealed class CustomResource(string name) : Resource(name);
 
    private sealed class CustomResourceWithWaitSupport(string name) : Resource(name), IResourceWithWaitSupport, IResourceWithConnectionString
    {
        public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{Name}-connection-string");
    }
 
    private sealed class CustomChildResource(string name, IResource parent) : Resource(name), IResourceWithParent
    {
        public IResource Parent => parent;
    }
 
    private sealed class ProjectA : IProjectMetadata
    {
        public string ProjectPath => "projectA";
 
        public LaunchSettings LaunchSettings { get; } = new();
    }
 
    private sealed class ProjectB : IProjectMetadata
    {
        public string ProjectPath => "projectB";
        public LaunchSettings LaunchSettings { get; } = new();
    }
 
    private abstract class ResourceWithConnectionString(string name)
        : Resource(name), IResourceWithConnectionString
    {
        protected abstract ReferenceExpression ConnectionString { get; }
 
        public ReferenceExpression ConnectionStringExpression
        {
            get
            {
                if (this.TryGetLastAnnotation<ConnectionStringRedirectAnnotation>(out var connectionStringAnnotation))
                {
                    return connectionStringAnnotation.Resource.ConnectionStringExpression;
                }
 
                return ConnectionString;
            }
        }
    }
 
    private sealed class ParentResourceWithConnectionString(string name) : ResourceWithConnectionString(name)
    {
        protected override ReferenceExpression ConnectionString =>
            ReferenceExpression.Create($"Server=localhost:8000");
    }
 
    private sealed class ChildResourceWithConnectionString(
        string name,
        Dictionary<string, string> kvConnectionString,
        IResourceWithConnectionString parent
    )
        : ResourceWithConnectionString(name), IResourceWithParent
    {
        private string SubConnectionString =>
            string.Join(';', kvConnectionString.Select(kv => $"{kv.Key}={kv.Value}"));
 
        protected override ReferenceExpression ConnectionString =>
            ReferenceExpression.Create($"{parent};{SubConnectionString}");
 
        public IResource Parent { get; } = parent;
    }
 
    private sealed class TestResourceWithConnectionString(string name, string connectionString, string? databaseName = null, string? host = null, ReferenceExpression? unresolvedProperty = null)
        : Resource(name), IResourceWithConnectionString
    {
        public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{connectionString}");
 
        public ValueTask<string?> GetConnectionStringAsync(CancellationToken cancellationToken = default)
        {
            return ValueTask.FromResult<string?>(connectionString);
        }
 
        public IEnumerable<KeyValuePair<string, ReferenceExpression>> GetConnectionProperties()
        {
            if (databaseName is not null)
            {
                yield return new("DatabaseName", ReferenceExpression.Create($"{databaseName}"));
            }
 
            if (host is not null)
            {
                yield return new("Host", ReferenceExpression.Create($"{host}"));
            }
 
            if (unresolvedProperty is not null)
            {
                yield return new("Unavailable", unresolvedProperty);
            }
        }
    }
 
    [Fact]
    public async Task ContainerChildResourcesWithOwnLifetimeDoNotReceiveParentStateChanges()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parentContainer = builder.AddContainer("parent-container", "parent-image");
        var childContainer = builder.AddContainer("child-container", "child-image")
            .WithParentRelationship(parentContainer);
        var customChild = builder.AddResource(new CustomChildResource("custom-child", parentContainer.Resource));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        // Initialize resources
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        // Simulate parent container state change
        await events.PublishAsync(new OnResourceChangedContext(
            CancellationToken.None,
            KnownResourceTypes.Container,
            parentContainer.Resource,
            "parent-container-dcp",
            new ResourceStatus(KnownResourceStates.FailedToStart, null, null),
            PreviousState: null,
            snapshot => snapshot with { State = KnownResourceStates.FailedToStart }));
 
        // Check final states
        var parentState = resourceNotificationService.TryGetCurrentState("parent-container-dcp", out var parentEvent) ? parentEvent.Snapshot.State?.Text : null;
        var childContainerState = resourceNotificationService.TryGetCurrentState(childContainer.Resource.Name, out var childContainerEvent) ? childContainerEvent.Snapshot.State?.Text : null;
        var customChildState = resourceNotificationService.TryGetCurrentState(customChild.Resource.Name, out var customChildEvent) ? customChildEvent.Snapshot.State?.Text : null;
 
        // Parent should have the new state
        Assert.Equal(KnownResourceStates.FailedToStart, parentState);
 
        // Child container (has own lifetime) should NOT receive parent state
        Assert.NotEqual(KnownResourceStates.Running, childContainerState);
 
        // Custom child (does not have own lifetime) SHOULD receive parent state
        Assert.Equal(KnownResourceStates.FailedToStart, customChildState);
    }
 
    [Fact]
    public async Task ProjectChildResourcesWithOwnLifetimeDoNotReceiveParentStateChanges()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parentContainer = builder.AddContainer("parent-container", "parent-image");
        var childProject = builder.AddProject<ProjectA>("child-project")
            .WithParentRelationship(parentContainer);
        var customChild = builder.AddResource(new CustomChildResource("custom-child", parentContainer.Resource));
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        // Initialize resources
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        // Simulate parent container state change
        await events.PublishAsync(new OnResourceChangedContext(
            CancellationToken.None,
            KnownResourceTypes.Container,
            parentContainer.Resource,
            "parent-container-dcp",
            new ResourceStatus(KnownResourceStates.FailedToStart, null, null),
            PreviousState: null,
            snapshot => snapshot with { State = KnownResourceStates.FailedToStart }));
 
        // Check final states
        var parentState = resourceNotificationService.TryGetCurrentState("parent-container-dcp", out var parentEvent) ? parentEvent.Snapshot.State?.Text : null;
        var childProjectState = resourceNotificationService.TryGetCurrentState(childProject.Resource.Name, out var childProjectEvent) ? childProjectEvent.Snapshot.State?.Text : null;
        var customChildState = resourceNotificationService.TryGetCurrentState(customChild.Resource.Name, out var customChildEvent) ? customChildEvent.Snapshot.State?.Text : null;
 
        // Parent should have the new state
        Assert.Equal(KnownResourceStates.FailedToStart, parentState);
 
        // Child project (has own lifetime) should NOT receive parent state
        Assert.NotEqual(KnownResourceStates.Running, childProjectState);
 
        // Custom child (does not have own lifetime) SHOULD receive parent state
        Assert.Equal(KnownResourceStates.FailedToStart, customChildState);
    }
 
    [Fact]
    public async Task WithChildRelationshipUsingResourceBuilderSetsParentPropertyCorrectly()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parent = builder.AddContainer("parent", "image");
        var child = builder.AddContainer("child", "image");
        var child2 = builder.AddContainer("child2", "image");
 
        parent.WithChildRelationship(child)
              .WithChildRelationship(child2);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        string? parentResourceId = null;
        string? childParentResourceId = null;
        string? child2ParentResourceId = null;
        var watchResourceTask = Task.Run(async () =>
        {
            await foreach (var item in resourceNotificationService.WatchAsync())
            {
                if (item.Resource == parent.Resource)
                {
                    parentResourceId = item.ResourceId;
                }
                else if (item.Resource == child.Resource)
                {
                    childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
                else if (item.Resource == child2.Resource)
                {
                    child2ParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
 
                if (parentResourceId != null && childParentResourceId != null && child2ParentResourceId != null)
                {
                    return;
                }
            }
        });
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await watchResourceTask.DefaultTimeout();
 
        Assert.Equal(parentResourceId, childParentResourceId);
        Assert.Equal(parentResourceId, child2ParentResourceId);
    }
 
    [Fact]
    public async Task WithChildRelationshipUsingResourceSetsParentPropertyCorrectly()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parent = builder.AddContainer("parent", "image");
        var child = builder.AddContainer("child", "image");
        var child2 = builder.AddContainer("child2", "image");
 
        parent.WithChildRelationship(child.Resource)
              .WithChildRelationship(child2.Resource);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        string? parentResourceId = null;
        string? childParentResourceId = null;
        string? child2ParentResourceId = null;
        var watchResourceTask = Task.Run(async () =>
        {
            await foreach (var item in resourceNotificationService.WatchAsync())
            {
                if (item.Resource == parent.Resource)
                {
                    parentResourceId = item.ResourceId;
                }
                else if (item.Resource == child.Resource)
                {
                    childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
                else if (item.Resource == child2.Resource)
                {
                    child2ParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
 
                if (parentResourceId != null && childParentResourceId != null && child2ParentResourceId != null)
                {
                    return;
                }
            }
        });
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await watchResourceTask.DefaultTimeout();
 
        Assert.Equal(parentResourceId, childParentResourceId);
        Assert.Equal(parentResourceId, child2ParentResourceId);
    }
 
    [Fact]
    public async Task WithChildRelationshipWorksWithProjects()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.WithTestAndResourceLogging(testOutputHelper);
 
        var parentProject = builder.AddProject<ProjectA>("parent-project");
        var childProject = builder.AddProject<ProjectB>("child-project");
 
        parentProject.WithChildRelationship(childProject);
 
        using var app = builder.Build();
        var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var events = new DcpExecutorEvents();
        var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
 
        var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
        await appOrchestrator.RunApplicationAsync();
 
        string? parentProjectResourceId = null;
        string? childProjectParentResourceId = null;
        var watchResourceTask = Task.Run(async () =>
        {
            await foreach (var item in resourceNotificationService.WatchAsync())
            {
                if (item.Resource == parentProject.Resource)
                {
                    parentProjectResourceId = item.ResourceId;
                }
                else if (item.Resource == childProject.Resource)
                {
                    childProjectParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
                }
 
                if (parentProjectResourceId != null && childProjectParentResourceId != null)
                {
                    return;
                }
            }
        });
 
        await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
 
        await watchResourceTask.DefaultTimeout();
 
        Assert.Equal(parentProjectResourceId, childProjectParentResourceId);
    }
 
    private sealed class ThrowingValueProvider : IValueProvider, IManifestExpressionProvider
    {
        public string ValueExpression => "{throwing.value}";
 
        public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
        {
            throw new InvalidOperationException("The connection property isn't available.");
        }
    }
}
#pragma warning restore CS0618