diff --git a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs index ca9837edcf..4e5aae0e23 100644 --- a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs +++ b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs @@ -88,8 +88,78 @@ public async Task AspireFixture_AllHealthy_Succeeds_AfterFix(CancellationToken c } } + [Test] + public async Task ShouldWaitForResource_IncludesComputeResource() + { + var fixture = new InspectableFixture(); + var regular = new FakeContainerResource("my-container"); + + await Assert.That(fixture.TestShouldWaitForResource(regular)).IsTrue(); + } + + /// + /// Regression test for https://github.com/thomhurst/TUnit/issues/5260 (Aspire 13.2.0+). + /// + /// Aspire 13.2.0 introduced ProjectRebuilderResource: an IComputeResource that also implements + /// IResourceWithParent. Without the fix, ShouldWaitForResource returns true for it, causing + /// WaitForResourceHealthyAsync to hang (it never emits healthy/running state). + /// + [Test] + public async Task ShouldWaitForResource_ExcludesIResourceWithParent() + { + var fixture = new InspectableFixture(); + var regular = new FakeContainerResource("my-container"); + var rebuilder = new FakeRebuilderResource("my-container-rebuilder", regular); + + await Assert.That(fixture.TestShouldWaitForResource(rebuilder)).IsFalse(); + } + + [Test] + public async Task ShouldWaitForResource_ExcludesNonComputeResource() + { + var fixture = new InspectableFixture(); + var paramResource = new FakeNonComputeResource("my-param"); + + await Assert.That(fixture.TestShouldWaitForResource(paramResource)).IsFalse(); + } + private sealed class HealthyFixture : AspireFixture { protected override TimeSpan ResourceTimeout => TimeSpan.FromSeconds(60); } + + /// + /// Exposes for unit testing. + /// + private sealed class InspectableFixture : AspireFixture + { + public bool TestShouldWaitForResource(IResource resource) + => ShouldWaitForResource(resource); + } + + /// A plain IComputeResource with no parent. + private sealed class FakeContainerResource(string name) : IComputeResource + { + public string Name => name; + public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection(); + } + + /// A non-compute resource (e.g. ParameterResource, ConnectionStringResource). + private sealed class FakeNonComputeResource(string name) : IResource + { + public string Name => name; + public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection(); + } + + /// + /// Simulates ProjectRebuilderResource from Aspire 13.2.0: + /// an IComputeResource that also implements IResourceWithParent. + /// + private sealed class FakeRebuilderResource(string name, IResource parent) + : IComputeResource, IResourceWithParent + { + public string Name => name; + public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection(); + public IResource Parent => parent; + } } diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index 990de9d449..db9f8917ce 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -391,6 +391,35 @@ private async Task WaitForResourcesWithFailFastAsync( return; } + // Runtime safety net: skip resources whose snapshot is marked IsHidden. + // This catches hidden internal resources (e.g. ProjectRebuilderResource registered with + // IsHidden = true) even if they slip past the model-level ShouldWaitForResource filter. + var visibleNames = new List(resourceNames.Count); + List? hiddenNames = null; + + foreach (var name in resourceNames) + { + if (notificationService.TryGetCurrentState(name, out var ev) && ev.Snapshot.IsHidden) + { + hiddenNames ??= []; + hiddenNames.Add(name); + } + else + { + visibleNames.Add(name); + } + } + + if (hiddenNames is { Count: > 0 }) + { + LogProgress($"Skipping {hiddenNames.Count} hidden resource(s) at runtime: [{string.Join(", ", hiddenNames)}]"); + } + + if (visibleNames.Count == 0) + { + return; + } + // Track which resources have become ready (for timeout reporting) var readyResources = new ConcurrentBag(); @@ -398,10 +427,10 @@ private async Task WaitForResourcesWithFailFastAsync( using var failureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var targetState = waitForHealthy ? "healthy" : "running"; - LogProgress($"Waiting for {resourceNames.Count} resource(s) to become {targetState}: [{string.Join(", ", resourceNames)}]"); + LogProgress($"Waiting for {visibleNames.Count} resource(s) to become {targetState}: [{string.Join(", ", visibleNames)}]"); // Success path: wait for all resources to reach the desired state - var readyTask = Task.WhenAll(resourceNames.Select(async name => + var readyTask = Task.WhenAll(visibleNames.Select(async name => { if (waitForHealthy) { @@ -413,11 +442,11 @@ private async Task WaitForResourcesWithFailFastAsync( } readyResources.Add(name); - LogProgress($" Resource '{name}' is {targetState} ({readyResources.Count}/{resourceNames.Count})"); + LogProgress($" Resource '{name}' is {targetState} ({readyResources.Count}/{visibleNames.Count})"); })); // Fail-fast path: complete as soon as ANY resource enters FailedToStart - var failureTasks = resourceNames.Select(async name => + var failureTasks = visibleNames.Select(async name => { await notificationService.WaitForResourceAsync(name, KnownResourceStates.FailedToStart, failureCts.Token); return name; @@ -452,7 +481,7 @@ private async Task WaitForResourcesWithFailFastAsync( failureCts.Cancel(); var readySet = new HashSet(readyResources); - var pending = resourceNames.Where(n => !readySet.Contains(n)).ToList(); + var pending = visibleNames.Where(n => !readySet.Contains(n)).ToList(); var sb = new StringBuilder(); sb.Append("Resources not ready: ["); @@ -523,8 +552,38 @@ private async Task CollectResourceLogsAsync( return string.Join(Environment.NewLine, lines); } - // Opt-in: only wait for IComputeResource (containers, projects, executables). - // Non-compute resources (parameters, connection strings) never report healthy and would hang. + /// + /// Returns whether should be included in the wait list. + /// Override to add custom inclusion/exclusion logic. + /// + /// + /// + /// Default: waits for (containers, projects, executables) unless + /// the resource also implements . Child resources are assumed to + /// be internally managed by Aspire and should not be independently awaited. + /// + /// + /// For example, Aspire 13.2.0+ adds ProjectRebuilderResource (an + /// that implements ) for hot-reload support. It never emits a + /// healthy/running state and would cause a guaranteed timeout if waited on. + /// + /// + /// Non-compute resources (parameters, connection strings) are always excluded because they never + /// report healthy and would hang indefinitely. + /// + /// + /// Override to exclude a slow resource from the wait list: + /// + /// protected override bool ShouldWaitForResource(IResource resource) + /// => base.ShouldWaitForResource(resource) && resource.Name != "slow-service"; + /// + /// + /// + /// The resource to evaluate. + /// if the resource should be waited for; otherwise . + protected virtual bool ShouldWaitForResource(IResource resource) + => resource is IComputeResource && resource is not IResourceWithParent; + private List GetWaitableResourceNames(DistributedApplicationModel model) { var waitable = new List(); @@ -532,20 +591,20 @@ private List GetWaitableResourceNames(DistributedApplicationModel model) foreach (var r in model.Resources) { - if (r is not IComputeResource) + if (ShouldWaitForResource(r)) { - skipped ??= []; - skipped.Add(r.Name); + waitable.Add(r.Name); } else { - waitable.Add(r.Name); + skipped ??= []; + skipped.Add(r.Name); } } if (skipped is { Count: > 0 }) { - LogProgress($"Skipping {skipped.Count} non-compute resource(s): [{string.Join(", ", skipped)}]"); + LogProgress($"Skipping {skipped.Count} non-waitable resource(s): [{string.Join(", ", skipped)}]"); } return waitable;