Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 119 additions & 14 deletions TUnit.Aspire.Core/AspireFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public class AspireFixture<TAppHost> : IAsyncInitializer, IAsyncDisposable, ITes
private DistributedApplication? _app;
private OtlpReceiver? _otlpReceiver;

// Live resource-log forwarding (opt-in via Options.ForwardResourceLogs). The pump tasks tail
// WatchAsync until the CTS (linked to RunCancellationToken) is cancelled in teardown.
private CancellationTokenSource? _logForwardCts;
private Task? _logForwardTask;

// Captured state-transition timeline (recorded by the background monitor), folded into the
// exception when startup fails so the failure tells the whole story in one place. Guarded by
// a lock because the monitor records into it while a failing path reads it (the monitor is
Expand Down Expand Up @@ -105,26 +110,89 @@ public IAsyncDisposable WatchResourceLogs(string resourceName)
var cts = new CancellationTokenSource();
var loggerService = App.Services.GetRequiredService<ResourceLoggerService>();

_ = Task.Run(async () =>
_ = PumpResourceLogsAsync(loggerService, resourceName,
line => testContext.Output.WriteLine($"[{resourceName}] {line}"), cts.Token);

return new ResourceLogWatcher(cts);
}

/// <summary>
/// Reads a resource's console output via <see cref="ResourceLoggerService.WatchAsync(string)"/>
/// and hands each raw line to <paramref name="write"/> until <paramref name="token"/> is
/// cancelled. The stream replays the buffered backlog before tailing live output (so logs from
/// an already-exited resource are still delivered) and never completes on its own, so the token
/// is the only way it ends.
/// </summary>
private static async Task PumpResourceLogsAsync(ResourceLoggerService loggerService,
string resourceName, Action<string> write, CancellationToken token)
{
try
{
try
await foreach (var batch in loggerService.WatchAsync(resourceName)
.WithCancellation(token))
{
await foreach (var batch in loggerService.WatchAsync(resourceName)
.WithCancellation(cts.Token))
foreach (var line in batch)
{
foreach (var line in batch)
{
testContext.Output.WriteLine($"[{resourceName}] {line.Content}");
}
// LogLine is a readonly record struct — use .Content, never ToString().
write(line.Content);
}
}
catch (OperationCanceledException)
{
// Expected when the watcher is disposed
}
});
}
catch (OperationCanceledException)
{
// Expected when the watcher is disposed or the fixture is torn down.
}
}

return new ResourceLogWatcher(cts);
/// <summary>
/// Starts one background pump per selected resource, forwarding each line into the owning
/// test's captured output (see <see cref="AspireFixtureOptions.ForwardResourceLogs"/>).
/// </summary>
private void StartForwardingResourceLogs(DistributedApplication app,
DistributedApplicationModel model, AspireFixtureOptions options)
{
var names = SelectResourceLogNames(model.Resources, options);
if (names.Count == 0)
{
return;
}

// The pumps run on background threads where TestContext.Current (an AsyncLocal) does not
// flow, so it would read null there. Resolve the sink once here on the init thread: the
// owning test's output, or stderr when init runs outside a test (session-shared fixture).
var owner = TestContext.Current;

LogProgress($"Forwarding resource logs: [{string.Join(", ", names)}]");

_logForwardCts = CancellationTokenSource.CreateLinkedTokenSource(RunCancellationToken);
var token = _logForwardCts.Token;
var loggerService = app.Services.GetRequiredService<ResourceLoggerService>();

// Bind the sink per resource once, so the per-line path is a single write with no branch.
// PumpResourceLogsAsync is async and yields at its first await, so calling it directly
// (no Task.Run) still runs the pumps concurrently without an extra thread-pool hop.
_logForwardTask = Task.WhenAll(names.Select(name =>
{
Action<string> write = owner is not null
? line => owner.Output.WriteLine($" [{name}] {line}")
: line => LogProgress($" [{name}] {line}");
return PumpResourceLogsAsync(loggerService, name, write, token);
}));
}

/// <summary>
/// The resources whose logs to forward: the explicit <see cref="AspireFixtureOptions.ResourceLogNames"/>
/// subset when supplied, otherwise every resource <see cref="ShouldWaitForResource"/> selects.
/// </summary>
internal List<string> SelectResourceLogNames(IEnumerable<IResource> resources, AspireFixtureOptions options)
{
if (options.ResourceLogNames is { Count: > 0 } named)
{
var wanted = new HashSet<string>(named, StringComparer.Ordinal);
return resources.Select(r => r.Name).Where(wanted.Contains).ToList();
}

return resources.Where(ShouldWaitForResource).Select(r => r.Name).ToList();
}

// --- Configuration hooks (virtual) ---
Expand Down Expand Up @@ -197,6 +265,16 @@ protected virtual void ConfigureBuilder(IDistributedApplicationTestingBuilder bu
/// </remarks>
protected virtual bool EnableTelemetryCollection => true;

/// <summary>
/// Optional additional configuration. Override to opt in to features such as live resource
/// log forwarding:
/// <code>
/// protected override AspireFixtureOptions Options => new() { ForwardResourceLogs = true };
/// </code>
/// Default: an all-defaults instance (no behaviour change).
/// </summary>
protected virtual AspireFixtureOptions Options { get; } = new();

/// <summary>
/// Resource wait timeout. Default: 60 seconds.
/// </summary>
Expand Down Expand Up @@ -339,6 +417,15 @@ public virtual async Task InitializeAsync()
var resourceList = string.Join(", ", model.Resources.Select(r => r.Name));
LogProgress($"Starting application with resources: [{resourceList}]");

// Subscribe to resource logs BEFORE StartAsync: a resource that crashes during boot makes
// StartAsync throw/hang, so a post-start subscribe would never run and miss the crash logs.
// WatchAsync replays the backlog, so the earliest lines are still delivered.
var options = Options;
if (options.ForwardResourceLogs)
{
StartForwardingResourceLogs(_app, model, options);
}

// Monitor resource state changes in the background, covering BOTH startup and the
// resource-wait phase, so the captured timeline includes health-check-wait hangs.
// This also provides real-time visibility into container health check failures, SSL
Expand Down Expand Up @@ -558,6 +645,24 @@ private Task StopAndDisposeAsync()

private async Task StopAndDisposeCoreAsync()
{
// Stop the resource-log pumps first so nothing writes to the owner's output while the
// application is being torn down (a write to an already-finished test would leak, mirroring
// the OnTestEnd retry-leak concern). Boot-phase logs were already delivered live before this.
if (_logForwardCts is not null)
{
_logForwardCts.Cancel();
try
{
await _logForwardTask!; // set alongside the CTS in StartForwardingResourceLogs
}
catch
{
// Best-effort: pump tasks observe cancellation internally; ignore any straggler.
}

_logForwardCts.Dispose();
}

if (_otlpReceiver is not null && _app is not null)
{
// Give the SUT's BatchSpanProcessor a chance to flush trailing spans while the
Expand Down
46 changes: 46 additions & 0 deletions TUnit.Aspire.Core/AspireFixtureOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace TUnit.Aspire;

/// <summary>
/// Optional configuration for <see cref="AspireFixture{TAppHost}"/>, returned from the
/// fixture's <see cref="AspireFixture{TAppHost}.Options"/> hook.
/// </summary>
/// <remarks>
/// This is a small, additive options bag — the fixture's other knobs remain individual
/// virtual properties. Override <see cref="AspireFixture{TAppHost}.Options"/> to supply values:
/// <code>
/// protected override AspireFixtureOptions Options => new() { ForwardResourceLogs = true };
/// </code>
/// </remarks>
public sealed class AspireFixtureOptions
{
/// <summary>
/// When <c>true</c>, subscribes to each selected resource's console output (stdout and
/// stderr) as soon as the application is built and forwards every line, prefixed with the
/// resource name, into the captured output of the test that owns the fixture lifecycle.
/// Default: <c>false</c> (opt-in).
/// </summary>
/// <remarks>
/// <para>
/// Subscription starts before the application is started, so logs from a resource that
/// crashes during boot — before its OpenTelemetry exporter flushes — are still captured.
/// This is the common Aspire-test failure ("a resource won't come up") that OTel misses.
/// </para>
/// <para>
/// The lines attach to the fixture-owner test (the test whose execution triggered
/// initialization) for the whole life of the subscription. On a shared (session/class)
/// fixture that is whichever test triggered initialization; forwarding is not
/// per-request-correlated — for that, rely on the OTLP receiver
/// (<c>EnableTelemetryCollection</c>). When initialization runs outside any test, lines
/// fall back to the standard error stream for CI visibility.
/// </para>
/// </remarks>
public bool ForwardResourceLogs { get; set; }

/// <summary>
/// The resources whose logs to forward when <see cref="ForwardResourceLogs"/> is on.
/// When <c>null</c> or empty (the default), every waited-on resource is forwarded (those
/// selected by <c>ShouldWaitForResource</c> — containers, projects, executables). When
/// names are supplied, only those resources are forwarded, bypassing that filter.
/// </summary>
public IReadOnlyCollection<string>? ResourceLogNames { get; set; }
}
32 changes: 32 additions & 0 deletions TUnit.Aspire.Tests/Helpers/FakeResources.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Aspire.Hosting.ApplicationModel;

namespace TUnit.Aspire.Tests.Helpers;

/// <summary>
/// Minimal fake <see cref="IResource"/> implementations for unit-testing resource-filtering
/// logic (<c>ShouldWaitForResource</c>, log selection) without building or starting an app.
/// </summary>
internal sealed class FakeComputeResource(string name) : IComputeResource
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new();
}

/// <summary>A non-compute resource (e.g. ParameterResource, ConnectionStringResource).</summary>
internal sealed class FakeNonComputeResource(string name) : IResource
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new();
}

/// <summary>
/// An <see cref="IComputeResource"/> that also implements <see cref="IResourceWithParent"/>
/// (e.g. Aspire 13.2.0's <c>ProjectRebuilderResource</c>).
/// </summary>
internal sealed class FakeChildComputeResource(string name, IResource parent)
: IComputeResource, IResourceWithParent
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new();
public IResource Parent => parent;
}
75 changes: 75 additions & 0 deletions TUnit.Aspire.Tests/ResourceLogSelectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Aspire.Hosting.ApplicationModel;
using TUnit.Aspire.Tests.Helpers;
using TUnit.Assertions;
using TUnit.Assertions.Extensions;
using TUnit.Core;

namespace TUnit.Aspire.Tests;

/// <summary>
/// Pure (no Docker) tests for <see cref="AspireFixture{TAppHost}.SelectResourceLogNames"/>, the
/// resource-selection logic behind opt-in log forwarding. Constructs the fixture without starting
/// it, so no application is built.
/// </summary>
public class ResourceLogSelectionTests
{
private static readonly AspireFixture<Projects.TUnit_Aspire_Tests_AppHost> Fixture = new();

[Test]
public async Task ExplicitNames_ReturnsOnlyTheNamedSubset_Ordinal()
{
IReadOnlyList<IResource> resources =
[new FakeComputeResource("api"), new FakeComputeResource("chat"), new FakeComputeResource("db"), new FakeComputeResource("cache")];

var names = Fixture.SelectResourceLogNames(resources,
new AspireFixtureOptions { ResourceLogNames = ["chat", "api", "missing"] });

// Only resources that exist are returned; the unknown "missing" is dropped.
await Assert.That(names).Contains("api");
await Assert.That(names).Contains("chat");
await Assert.That(names).DoesNotContain("db");
await Assert.That(names).DoesNotContain("cache");
await Assert.That(names).DoesNotContain("missing");
}

[Test]
public async Task ExplicitNames_AreCaseSensitive()
{
IReadOnlyList<IResource> resources = [new FakeComputeResource("chat")];

var names = Fixture.SelectResourceLogNames(resources,
new AspireFixtureOptions { ResourceLogNames = ["Chat"] });

await Assert.That(names).IsEmpty();
}

[Test]
public async Task NoNames_FallsBackToWaitableResources()
{
var parent = new FakeComputeResource("api");
IReadOnlyList<IResource> resources =
[
new FakeComputeResource("api"), // compute, no parent -> waited on
new FakeNonComputeResource("db-password"), // non-compute -> excluded
new FakeChildComputeResource("api-pdb", parent), // compute w/ parent -> excluded
];

var names = Fixture.SelectResourceLogNames(resources, new AspireFixtureOptions());

await Assert.That(names).Contains("api");
await Assert.That(names).DoesNotContain("db-password");
await Assert.That(names).DoesNotContain("api-pdb");
}

[Test]
public async Task EmptyNames_IsTreatedAsNoNames()
{
IReadOnlyList<IResource> resources = [new FakeComputeResource("api"), new FakeNonComputeResource("param")];

var names = Fixture.SelectResourceLogNames(resources,
new AspireFixtureOptions { ResourceLogNames = [] });

await Assert.That(names).Contains("api");
await Assert.That(names).DoesNotContain("param");
}
}
33 changes: 4 additions & 29 deletions TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using TUnit.Aspire.Tests.Helpers;
using TUnit.Assertions;
using TUnit.Assertions.Extensions;
using TUnit.Core;
Expand Down Expand Up @@ -98,7 +99,7 @@ public async Task AllHealthy_Succeeds_OnWaitableResources(CancellationToken ct)
public async Task ShouldWaitForResource_IncludesComputeResource()
{
var inspectable = new InspectableFixture();
var regular = new FakeContainerResource("my-container");
var regular = new FakeComputeResource("my-container");

await Assert.That(inspectable.TestShouldWaitForResource(regular)).IsTrue();
}
Expand All @@ -114,8 +115,8 @@ public async Task ShouldWaitForResource_IncludesComputeResource()
public async Task ShouldWaitForResource_ExcludesIResourceWithParent()
{
var inspectable = new InspectableFixture();
var regular = new FakeContainerResource("my-container");
var rebuilder = new FakeRebuilderResource("my-container-rebuilder", regular);
var regular = new FakeComputeResource("my-container");
var rebuilder = new FakeChildComputeResource("my-container-rebuilder", regular);

await Assert.That(inspectable.TestShouldWaitForResource(rebuilder)).IsFalse();
}
Expand All @@ -137,30 +138,4 @@ private sealed class InspectableFixture : AspireFixture<Projects.TUnit_Aspire_Te
public bool TestShouldWaitForResource(IResource resource)
=> ShouldWaitForResource(resource);
}

/// <summary>A plain IComputeResource with no parent.</summary>
private sealed class FakeContainerResource(string name) : IComputeResource
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
}

/// <summary>A non-compute resource (e.g. ParameterResource, ConnectionStringResource).</summary>
private sealed class FakeNonComputeResource(string name) : IResource
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
}

/// <summary>
/// Simulates ProjectRebuilderResource from Aspire 13.2.0:
/// an IComputeResource that also implements IResourceWithParent.
/// </summary>
private sealed class FakeRebuilderResource(string name, IResource parent)
: IComputeResource, IResourceWithParent
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
public IResource Parent => parent;
}
}
Loading