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;