diff --git a/Directory.Packages.props b/Directory.Packages.props index 6a588aec29..ee86beb1a3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/TUnit.Aspire.Tests.AppHost/Program.cs b/TUnit.Aspire.Tests.AppHost/Program.cs new file mode 100644 index 0000000000..db6e28965f --- /dev/null +++ b/TUnit.Aspire.Tests.AppHost/Program.cs @@ -0,0 +1,6 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddParameter("my-secret", secret: true); +builder.AddContainer("nginx-no-healthcheck", "nginx"); + +builder.Build().Run(); diff --git a/TUnit.Aspire.Tests.AppHost/TUnit.Aspire.Tests.AppHost.csproj b/TUnit.Aspire.Tests.AppHost/TUnit.Aspire.Tests.AppHost.csproj new file mode 100644 index 0000000000..0a4d4e5466 --- /dev/null +++ b/TUnit.Aspire.Tests.AppHost/TUnit.Aspire.Tests.AppHost.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + true + false + + + + + + + diff --git a/TUnit.Aspire.Tests/TUnit.Aspire.Tests.csproj b/TUnit.Aspire.Tests/TUnit.Aspire.Tests.csproj new file mode 100644 index 0000000000..770bca144d --- /dev/null +++ b/TUnit.Aspire.Tests/TUnit.Aspire.Tests.csproj @@ -0,0 +1,24 @@ + + + + + + net10.0 + false + + + + + + + + + + + + + + + + + diff --git a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs new file mode 100644 index 0000000000..ca9837edcf --- /dev/null +++ b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs @@ -0,0 +1,95 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using Microsoft.Extensions.DependencyInjection; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +namespace TUnit.Aspire.Tests; + +/// +/// Reproduction and fix verification tests for https://github.com/thomhurst/TUnit/issues/5260 +/// +public class WaitForHealthyReproductionTests +{ + [Test] + [Category("Docker")] + public async Task ParameterResource_DoesNotImplement_IResourceWithoutLifetime_InAspire13_2(CancellationToken ct) + { + await using var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + await using var app = await builder.BuildAsync(); + + var model = app.Services.GetRequiredService(); + var paramResource = model.Resources.First(r => r.Name == "my-secret"); + + await Assert.That(paramResource is IResourceWithoutLifetime).IsFalse(); + } + + [Test] + [Category("Docker")] + public async Task ParameterResource_IsNot_IComputeResource(CancellationToken ct) + { + await using var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + await using var app = await builder.BuildAsync(); + + var model = app.Services.GetRequiredService(); + var paramResource = model.Resources.First(r => r.Name == "my-secret"); + var containerResource = model.Resources.First(r => r.Name == "nginx-no-healthcheck"); + + await Assert.That(paramResource is IComputeResource).IsFalse(); + await Assert.That(containerResource is IComputeResource).IsTrue(); + } + + [Test] + [Category("Docker")] + public async Task WaitForResourceHealthyAsync_OnParameterResource_Hangs(CancellationToken ct) + { + await using var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + await using var app = await builder.BuildAsync(); + await app.StartAsync(ct); + + var notificationService = app.Services.GetRequiredService(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(15)); + + var timedOut = false; + try + { + await notificationService.WaitForResourceHealthyAsync("my-secret", cts.Token); + } + catch (OperationCanceledException) + { + timedOut = true; + } + catch (InvalidOperationException) + { + // Aspire throws InvalidOperationException for resources that can never become healthy + timedOut = true; + } + + await Assert.That(timedOut).IsTrue(); + } + + [Test] + [Category("Docker")] + public async Task AspireFixture_AllHealthy_Succeeds_AfterFix(CancellationToken ct) + { + var fixture = new HealthyFixture(); + + try + { + await fixture.InitializeAsync(); + } + finally + { + await fixture.DisposeAsync(); + } + } + + private sealed class HealthyFixture : AspireFixture + { + protected override TimeSpan ResourceTimeout => TimeSpan.FromSeconds(60); + } +} diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index 43efba8bb3..990de9d449 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -523,6 +523,8 @@ 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. private List GetWaitableResourceNames(DistributedApplicationModel model) { var waitable = new List(); @@ -530,7 +532,7 @@ private List GetWaitableResourceNames(DistributedApplicationModel model) foreach (var r in model.Resources) { - if (r is IResourceWithoutLifetime) + if (r is not IComputeResource) { skipped ??= []; skipped.Add(r.Name); @@ -543,7 +545,7 @@ private List GetWaitableResourceNames(DistributedApplicationModel model) if (skipped is { Count: > 0 }) { - LogProgress($"Skipping {skipped.Count} resource(s) without lifecycle: [{string.Join(", ", skipped)}]"); + LogProgress($"Skipping {skipped.Count} non-compute resource(s): [{string.Join(", ", skipped)}]"); } return waitable; diff --git a/TUnit.CI.slnx b/TUnit.CI.slnx index a4d6e48a70..d7d8bd8f79 100644 --- a/TUnit.CI.slnx +++ b/TUnit.CI.slnx @@ -75,6 +75,8 @@ + + diff --git a/TUnit.Pipeline/Modules/RunAspireTestsModule.cs b/TUnit.Pipeline/Modules/RunAspireTestsModule.cs new file mode 100644 index 0000000000..df4939cc57 --- /dev/null +++ b/TUnit.Pipeline/Modules/RunAspireTestsModule.cs @@ -0,0 +1,43 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Extensions; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using ModularPipelines.Options; + +namespace TUnit.Pipeline.Modules; + +[NotInParallel("NetworkTests"), RunOnLinuxOnly, RunOnWindowsOnly] +public class RunAspireTestsModule : Module +{ + protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + var project = context.Git().RootDirectory.FindFile(x => x.Name == "TUnit.Aspire.Tests.csproj").AssertExists(); + + return await context.DotNet().Run(new DotNetRunOptions + { + Project = project.Name, + NoBuild = true, + Configuration = "Release", + Framework = "net10.0", + Arguments = ["--hangdump", "--hangdump-filename", "hangdump.aspire-tests.dmp", "--hangdump-timeout", "5m"], + }, new CommandExecutionOptions + { + WorkingDirectory = project.Folder!.Path, + EnvironmentVariables = new Dictionary + { + ["DISABLE_GITHUB_REPORTER"] = "true", + }, + LogSettings = new CommandLoggingOptions + { + ShowCommandArguments = true, + ShowStandardError = true, + ShowExecutionTime = true, + ShowExitCode = true + } + }, cancellationToken); + } +} diff --git a/TUnit.slnx b/TUnit.slnx index 4c5bf61d82..7b9a173ff3 100644 --- a/TUnit.slnx +++ b/TUnit.slnx @@ -77,6 +77,8 @@ + +