feat: register HTML report as MTP session artifact#5270
Conversation
Implements IDataProducer on HtmlReporter with DataTypesProduced=[SessionFileArtifact] and calls PublishArtifactAsync after writing the HTML file so the platform can surface the report as a session artifact. Adds unit tests to verify the interface and DataTypesProduced contract.
Make PublishArtifactAsync internal and expose TUnit.Engine internals to TUnit.Engine.Tests (signed with the same strongname.snk), then add three tests covering the happy path, no-op when session context is absent, and no-op when the output file does not exist. Also adds a comment explaining why AddDataProducer registration is not required.
…AfterRunAsync Move HTML report generation and SessionFileArtifact publishing from AfterRunAsync (called after the message bus is drained/disabled) to OnTestSessionFinishingAsync (called while the bus is still active). Register HtmlReporter as ITestSessionLifetimeHandler so it receives the OnTestSessionFinishingAsync callback with a live message bus and the session UID. The application-level IMessageBus is injected via SetMessageBus in the AddTestSessionLifetimeHandler factory lambda. Remove HtmlReporter from TUnitTestFramework constructor since session context injection is no longer needed there.
SetSessionContext(IMessageBus, SessionUid) was no longer called from production code. Remove it and update HtmlReporterTests to call SetMessageBus(bus) directly instead. MTP auto-registers IDataConsumer via AddTestSessionLifetimeHandler, so no AddDataConsumer registration is needed for HtmlReporter (adding one would cause a "Consumer registered two time" crash).
…t overload - Change HtmlReporter from public to internal (InternalsVisibleTo already covers TUnit.Engine.Tests, TUnit.UnitTests, and TUnit.AspNetCore) - Remove the SessionUid(string.Empty) hack from the test-only overload; the internal PublishArtifactAsync now takes an explicit SessionUid so tests pass a real value instead of relying on a string.Empty placeholder
There was a problem hiding this comment.
Code Review
Overall, this is a solid and well-motivated change — moving artifact publishing to OnTestSessionFinishingAsync is the right call to avoid the drained message bus problem. The test coverage is appreciated. That said, I have a few concerns worth addressing.
Critical: Missing AddDataConsumer registration may produce empty reports
The old code explicitly called testApplicationBuilder.TestHost.AddDataConsumer(...) for htmlReporter, which is what caused MTP to route TestNodeUpdateMessage events to ConsumeAsync and populate _updates. The new code replaces this with AddTestSessionLifetimeHandler.
The PR description states: "MTP auto-registers it as IDataConsumer". But this is an undocumented MTP implementation detail. If AddTestSessionLifetimeHandler does not auto-discover IDataConsumer implementations, then ConsumeAsync is never called, _updates stays empty, and OnTestSessionFinishingAsync hits the early return:
if (_updates.Count == 0)
{
return; // No HTML report generated
}This would be a silent regression — no error, no warning, just an empty/missing report. The JUnitReporter and GitHubReporter both retain explicit AddDataConsumer calls; HtmlReporter should too, unless you can point to MTP documentation that confirms the auto-registration behaviour.
Suggestion: Add testApplicationBuilder.TestHost.AddDataConsumer(_ => htmlReporter) back alongside the AddTestSessionLifetimeHandler registration, or document the MTP API contract that makes this safe to omit.
Misleading comment in AfterRunAsync
public Task AfterRunAsync(int exitCode, CancellationToken cancellation)
{
// Report generation and artifact publishing happen in OnTestSessionFinishingAsync
// (before the message bus is drained), so AfterRunAsync only handles GitHub integration.
return Task.CompletedTask;
}The comment says "AfterRunAsync only handles GitHub integration", but the implementation does nothing — the GitHub integration was also moved to OnTestSessionFinishingAsync. The comment will mislead future readers into thinking there's some GitHub work here. Either drop the comment or update it:
// All work has moved to OnTestSessionFinishingAsync, which runs before the message bus is drained.Inaccurate test name
public async Task PublishArtifactAsync_Is_NoOp_When_SessionContext_Not_Set()The test name says SessionContext_Not_Set, but what's actually being tested is that _messageBus is null (i.e. SetMessageBus was never called). There's no "session context" involved — SessionUid is passed as a parameter and is always present. A clearer name would be PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected.
Minor: csproj indentation regression
In TUnit.Engine.Tests.csproj, the newly added PackageReference for CrashDump has inconsistent indentation (4 spaces instead of 8):
+ <PackageReference Include="Microsoft.Testing.Platform" />
+ <PackageReference Include="Microsoft.Testing.Extensions.CrashDump" /> ← wrong indentWhat's done well
- Moving report generation and artifact publishing to
OnTestSessionFinishingAsyncis the right fix.AfterRunAsyncis too late; the bus can already be drained. - Injecting
IMessageBusvia theserviceProviderinAddTestSessionLifetimeHandleris idiomatic and matches how MTP services should be resolved. - The 5 unit tests cover the key code paths cleanly (happy path, null bus, missing file).
PublishArtifactAsyncbeinginternalkeeps the API surface clean while enabling testability viaInternalsVisibleTo.- The
TUnitTestFramework.csvariable extraction is a trivial but harmless readability improvement.
…x WriteFileAsync bool return, clean up AfterRunAsync comment and nullability
There was a problem hiding this comment.
Follow-up Review
Thanks for the latest refactor commit (36f72f1). A few items from the previous review have been addressed, but the critical one remains open.
Critical (still unresolved): Missing AddDataConsumer registration
The previous review flagged that removing AddDataConsumer may cause ConsumeAsync to never be called, leaving _updates empty and producing no HTML report. This is still not addressed in the latest diff. The code now has:
testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => htmlReporter);
testApplicationBuilder.TestHost.AddTestSessionLifetimeHandler(serviceProvider => { ... return htmlReporter; });But there is no AddDataConsumer call for htmlReporter. The PR description asserts MTP auto-discovers IDataConsumer from AddTestSessionLifetimeHandler, but this remains unverified — the JUnitReporter and GitHubReporter both retain explicit AddDataConsumer registrations. A silent regression (no error, just an empty/missing report) is the risk here.
Action needed: Either add testApplicationBuilder.TestHost.AddDataConsumer(_ => htmlReporter) back, or add a comment citing the specific MTP source/documentation that confirms auto-discovery from AddTestSessionLifetimeHandler.
New issue (introduced in 36f72f1): Lost test coverage for write-failure path
The previous commit had a test PublishArtifactAsync_Is_NoOp_When_File_Does_Not_Exist. That test was removed and replaced with PublishArtifactAsync_Publishes_With_Correct_SessionUid — which is good coverage, but the file-not-exists / write-failure path now has no test.
The new gating logic (publish only when WriteFileAsync returns true) means an artifact won't be published if the write fails, but there's nothing asserting this. Given that WriteFileAsync returns false on failure without throwing, this is a subtle path worth covering.
Minor (still unresolved): Misleading test name
PublishArtifactAsync_Is_NoOp_When_SessionContext_Not_Set still exists and still tests the wrong thing — it tests that the method is a no-op when _messageBus is null, not when a session context is absent. Suggested name: PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected.
Minor (still unresolved): csproj indentation
In TUnit.Engine.Tests.csproj, the CrashDump package reference still has 4-space indentation while all others use 8 spaces.
What was addressed ✅
AfterRunAsynccomment is now accurate:// All work happens in OnTestSessionFinishingAsync.MockExtensionwas extracted to a sharedTestInfrastructure/MockExtension.cs— eliminates the duplication across three test files cleanly.TUnitTestFramework.csvariable inlining is a small but clean improvement.- Gating artifact publish on
WriteFileAsyncreturningtruerather than re-checkingFile.Existsis a good simplification that avoids a race condition.
There was a problem hiding this comment.
Follow-up Review
Thanks for the latest refactor commit (36f72f1). A few items from the previous review have been addressed, but the critical one remains open.
Critical (still unresolved): Missing AddDataConsumer registration
The previous review flagged that removing AddDataConsumer may cause ConsumeAsync to never be called, leaving _updates empty and producing no HTML report. This is still not addressed in the latest diff. The code now has:
testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => htmlReporter);
testApplicationBuilder.TestHost.AddTestSessionLifetimeHandler(serviceProvider => { ... return htmlReporter; });But there is no AddDataConsumer call for htmlReporter. The PR description asserts MTP auto-discovers IDataConsumer from AddTestSessionLifetimeHandler, but this remains unverified — the JUnitReporter and GitHubReporter both retain explicit AddDataConsumer registrations. A silent regression (no error, just an empty/missing report) is the risk here.
Action needed: Either add testApplicationBuilder.TestHost.AddDataConsumer(_ => htmlReporter) back, or add a comment citing the specific MTP source/documentation that confirms auto-discovery from AddTestSessionLifetimeHandler.
New issue (introduced in 36f72f1): Lost test coverage for write-failure path
The previous commit had a test PublishArtifactAsync_Is_NoOp_When_File_Does_Not_Exist. That test was removed and replaced with PublishArtifactAsync_Publishes_With_Correct_SessionUid — which is good coverage, but the file-not-exists / write-failure path now has no test. The new gating logic (publish only when WriteFileAsync returns true) means an artifact won't be published if the write fails, but there's nothing asserting this.
Minor (still unresolved): Misleading test name
PublishArtifactAsync_Is_NoOp_When_SessionContext_Not_Set still tests the wrong scenario — it tests that the method is a no-op when _messageBus is null, not when a session context is absent. Suggested name: PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected.
Minor (still unresolved): csproj indentation
In TUnit.Engine.Tests.csproj, the CrashDump package reference still has 4-space indentation while all others use 8 spaces.
What was addressed
AfterRunAsynccomment is now accurate:// All work happens in OnTestSessionFinishingAsync.MockExtensionextracted to sharedTestInfrastructure/MockExtension.cs— eliminates duplication across three test files.TUnitTestFramework.csvariable inlining is a clean improvement.- Gating artifact publish on
WriteFileAsyncreturningtruerather than re-checkingFile.Existsis a good simplification that avoids a race condition.
There was a problem hiding this comment.
Follow-up Review
Thanks for the latest refactor commit (36f72f1). A few items from the previous review have been addressed, but the critical one remains open.
Critical (still unresolved): Missing AddDataConsumer registration
The previous review flagged that removing AddDataConsumer may cause ConsumeAsync to never be called, leaving _updates empty and producing no HTML report. This is still not addressed in the latest diff. The code now has:
testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => htmlReporter);
testApplicationBuilder.TestHost.AddTestSessionLifetimeHandler(serviceProvider => { ... return htmlReporter; });But there is no AddDataConsumer call for htmlReporter. The PR description asserts MTP auto-discovers IDataConsumer from AddTestSessionLifetimeHandler, but this remains unverified — the JUnitReporter and GitHubReporter both retain explicit AddDataConsumer registrations. A silent regression (no error, just an empty/missing report) is the risk here.
Action needed: Either add testApplicationBuilder.TestHost.AddDataConsumer(_ => htmlReporter) back, or add a comment citing the specific MTP source/documentation that confirms auto-discovery from AddTestSessionLifetimeHandler.
New issue (introduced in 36f72f1): Lost test coverage for write-failure path
The previous commit had a test PublishArtifactAsync_Is_NoOp_When_File_Does_Not_Exist. That test was removed and replaced with PublishArtifactAsync_Publishes_With_Correct_SessionUid — which is good coverage, but the file-not-exists / write-failure path now has no test. The new gating logic (publish only when WriteFileAsync returns true) means an artifact won't be published if the write fails, but there's nothing asserting this.
Minor (still unresolved): Misleading test name
PublishArtifactAsync_Is_NoOp_When_SessionContext_Not_Set still tests the wrong scenario — it tests that the method is a no-op when _messageBus is null, not when a session context is absent. Suggested name: PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected.
Minor (still unresolved): csproj indentation
In TUnit.Engine.Tests.csproj, the CrashDump package reference still has 4-space indentation while all others use 8 spaces.
What was addressed
AfterRunAsynccomment is now accurate:// All work happens in OnTestSessionFinishingAsync.MockExtensionextracted to sharedTestInfrastructure/MockExtension.cs— eliminates duplication across three test files.TUnitTestFramework.csvariable inlining is a clean improvement.- Gating artifact publish on
WriteFileAsyncreturningtruerather than re-checkingFile.Existsis a good simplification that avoids a race condition.
|
Addressing the review items: Critical (AddDataConsumer): Pushing back on this one. We empirically verified the auto-registration: when we tried to add an explicit AfterRunAsync comment: Already fixed in a prior commit — the method is now Test name: Fixed — csproj indentation: Fixed. |
There was a problem hiding this comment.
Code Review
Summary
The core architectural decision here — moving report generation from AfterRunAsync to OnTestSessionFinishingAsync to ensure the MTP message bus is still live — is sound and well-reasoned. The PR is clean and the cleanup of MockExtension into a shared location is a nice improvement.
Critical: IDataConsumer Auto-Registration Assumption
The most important thing to verify before merging: the AddDataConsumer call was removed and replaced with AddTestSessionLifetimeHandler. The PR description states that MTP auto-registers the handler as IDataConsumer. If this assumption is wrong, ConsumeAsync will never be called and the HTML report will silently be empty.
In TestApplicationBuilderExtensions.cs (before):
testApplicationBuilder.TestHost.AddDataConsumer(serviceProvider => { ... return htmlReporter; });After: this call is gone. It's worth adding a comment documenting which version of MTP introduced this auto-registration behavior (or a link to the relevant MTP source), so future maintainers understand why there's no explicit AddDataConsumer call.
Design: Property Injection for IMessageBus
The SetMessageBus setter is forced by the architecture (reporter is instantiated before the service provider is available), but it's worth noting a cleaner alternative: MTP's ITestSessionLifetimeHandler receives an ITestSessionContext — if the context exposes a way to publish data, that would eliminate the need for the setter entirely.
As written, there's a subtle initialization ordering dependency: SetMessageBus must be called before OnTestSessionFinishingAsync, which is guaranteed only because AddTestSessionLifetimeHandler's factory runs at startup. This is correct but fragile — a comment explaining the constraint would help:
// SetMessageBus is called by the AddTestSessionLifetimeHandler factory before any
// session events fire, so _messageBus is guaranteed to be set by OnTestSessionFinishingAsync.Test Gap: Missing "File Not Exists" No-Op Test
The PR description lists 5 tests including a "file-not-exists no-op" case, but looking at HtmlReporterTests.cs there are only 4 distinct behavioral tests. The file-exists-check lives in WriteFileAsync (returning false), which gates the PublishArtifactAsync call in OnTestSessionFinishingAsync — but PublishArtifactAsync itself doesn't check for file existence. Consider either:
- Adding a test for the integration path where
WriteFileAsyncreturnsfalse(file write fails → artifact not published) - Or documenting that the no-op case is covered by the
WriteFileAsyncreturn value
Minor: Test Assertion Could Be More Explicit
PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected passes by not throwing, which is correct but implicit. A more expressive pattern:
var bus = new CapturingMessageBus();
// Note: no reporter.SetMessageBus(bus) call
await reporter.PublishArtifactAsync(tempFile, new SessionUid("s"), CancellationToken.None);
bus.Published.ShouldBeEmpty(); // explicit: no events published without a busThis makes the intent clearer and would catch a regression where the null check is removed.
What's Good
- Moving work to
OnTestSessionFinishingAsyncis the right call — the timing explanation in the PR description is clear and the fix is correct. WriteFileAsyncreturningboolinstead ofvoidis a cleaner design that makes the control flow explicit.- Refactoring
MockExtensionto a shared file is a small but worthwhile cleanup. - The
#pragma warning disable TPEXPscoping is appropriate.
Overall this is solid work. The main ask is verifying (and documenting) the IDataConsumer auto-registration assumption since that's the load-bearing correctness guarantee of this change.
|
Addressed the open items from the latest review: Critical (AddDataConsumer auto-registration): Added a comment in SetMessageBus ordering: Added a comment on Write-failure test gap: NoOp test assertion: Already fixed in prior commits: test name ( |
There was a problem hiding this comment.
Code Review (follow-up — final state)
The previous critical concern about AddDataConsumer auto-registration has been properly addressed in the latest commits with both empirical evidence and in-code documentation. This review reflects the current state of the PR.
Architecture: Approved
Moving artifact publishing from AfterRunAsync to OnTestSessionFinishingAsync is the correct design decision — the MTP message bus is guaranteed live during session finishing, but may already be drained by the time AfterRunAsync fires. The code comment in TestApplicationBuilderExtensions.cs clearly explains why the explicit AddDataConsumer call was intentionally removed:
// MTP auto-registers IDataConsumer implementations returned from AddTestSessionLifetimeHandler,
// so no separate AddDataConsumer call is needed. Adding one causes a startup exception:
// "Consumer registered two time for data type TestNodeUpdateMessage".This is the right level of documentation for a non-obvious MTP contract.
Minor: CapturingMessageBus nested inside HtmlReporterTests
CapturingMessageBus is a broadly-useful test double that other reporter tests might benefit from. Consider moving it to TestInfrastructure/ alongside MockExtension. This keeps the test class focused and makes the helper discoverable for future tests.
// TestInfrastructure/CapturingMessageBus.cs
internal sealed class CapturingMessageBus : IMessageBus { ... }This is not a blocker — just a consistency suggestion given that MockExtension was already extracted to that location in this very PR.
Minor: Vacuous assertion in no-op test
In PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected, the CapturingMessageBus is created but never passed to the reporter. Asserting bus.Published.ShouldBeEmpty() is technically correct, but it would remain empty regardless — the bus was never wired up. The assertion documents intent, but doesn't guard against a future regression where the reporter uses a different bus.
A slightly stronger test would verify that no exception is thrown and use Assert.DoesNotThrow or simply leave the implicit pass:
// This verifies the null-bus guard doesn't throw, not that a connected bus was untouched
await reporter.PublishArtifactAsync(tempFile, new SessionUid("test-session-1"), CancellationToken.None);
// Implicit pass — no exception means the null guard workedAgain, not a blocker — the current test is still useful as documentation.
Positive: WriteFileAsync → bool return
The refactor of WriteFileAsync to return bool and gate artifact publishing on a successful write is a clean improvement. It avoids publishing a SessionFileArtifact pointing to a file that doesn't exist.
PR Description vs. Changed Files
The PR description lists TUnit.Engine/Framework/TUnitTestFramework.cs as a changed file, but it's not in the diff. Looks like the description may be slightly stale from a prior iteration — not a concern, just noting it.
Summary
All previous review items have been addressed. The design is sound, the new tests are meaningful, and the MockExtension consolidation is a nice bonus cleanup. The two minor items above are suggestions, not blockers.
Approved for merge.
There was a problem hiding this comment.
Code Review (follow-up — current state)
All items from previous reviews have been resolved. This review captures the current state.
Addressed since last review ✅
- ** extracted to ** — consistent with . The suggestion from the prior review was implemented.
- Test name corrected — is accurate and self-documenting.
- ** auto-registration documented** — the inline comment in
TestApplicationBuilderExtensions.csexplains both why the explicit call was removed and that adding it back causes a duplicate-registration startup exception. This is the right level of documentation for a non-obvious MTP contract. - csproj indentation — correct 8-space indentation throughout.
One minor item (carry-over, non-blocking)
In PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected, the CapturingMessageBus is created but never wired to the reporter, so bus.Published.ShouldBeEmpty() will always pass regardless of what the reporter does. The assertion documents intent but doesn't protect against a future regression where the reporter resolves a bus via a different path.
Not a blocker — the implicit pass (no exception thrown) is the real assertion here. An optional comment like // bus is never wired; asserting empty to document the no-op contract would make the intent clear, but this is purely cosmetic.
Overall
The design is sound, all previous review concerns have been addressed, and the PR is clean. The core change — using OnTestSessionFinishingAsync with an injected IMessageBus rather than AfterRunAsync — is the right architectural fix for the drained-bus problem.
Approved for merge.
There was a problem hiding this comment.
Code Review (follow-up — current state)
All items from previous reviews have been resolved. This review captures the current state.
Addressed since last review
CapturingMessageBusextracted toTestInfrastructure/— consistent withMockExtension. The suggestion from the prior review was implemented.- Test name corrected —
PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injectedis accurate and self-documenting. AddDataConsumerauto-registration documented — the inline comment inTestApplicationBuilderExtensions.csexplains both why the explicit call was removed and that adding it back causes a duplicate-registration startup exception. This is the right level of documentation for a non-obvious MTP contract.- csproj indentation — correct 8-space indentation throughout.
One minor item (carry-over, non-blocking)
In PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected, the CapturingMessageBus is created but never wired to the reporter, so bus.Published.ShouldBeEmpty() will always pass regardless of what the reporter does. The assertion documents intent but does not protect against a future regression where the reporter resolves a bus via a different path.
Not a blocker — the implicit pass (no exception thrown) is the real assertion here. An optional comment like // bus is never wired; asserting empty to document the no-op contract would make the intent clear, but this is purely cosmetic.
Overall
The design is sound, all previous review concerns have been addressed, and the PR is clean. The core change — using OnTestSessionFinishingAsync with an injected IMessageBus rather than AfterRunAsync — is the correct architectural fix for the drained-bus problem.
Approved for merge.
Summary
HtmlReporternow implementsITestSessionLifetimeHandlerand publishes the generated HTML report as aSessionFileArtifactafter every test runOnTestSessionFinishingAsync(notAfterRunAsync) so the MTP message bus is still live when the artifact is publishedIMessageBusis injected viaAddTestSessionLifetimeHandler's service provider — this bus remains valid throughout the session close sequenceChanges
TUnit.Engine/Reporters/Html/HtmlReporter.cs— addsITestSessionLifetimeHandler; moves report generation + artifact publishing toOnTestSessionFinishingAsync; addsSetMessageBus(IMessageBus)for injectionTUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs— registersHtmlReporterviaAddTestSessionLifetimeHandler(MTP auto-registers it asIDataConsumer); injectsIMessageBusfrom service providerTUnit.Engine/Framework/TUnitTestFramework.cs— minor refactor only (no functional change)TUnit.Engine.Tests/HtmlReporterTests.cs— new: 5 unit tests coveringIDataProducermembership,DataTypesProduced, publish happy path, null-bus no-op, file-not-exists no-opTUnit.Engine/TUnit.Engine.csproj— addsInternalsVisibleToforTUnit.Engine.TestsTUnit.Engine.Tests/TUnit.Engine.Tests.csproj— adds MTP package reference and strong-name key forInternalsVisibleToTest plan
HtmlReporterTestspass (--treenode-filter "/*/*/HtmlReporterTests/*")TestResults/*.htmlexists with content)