Skip to content

Run wasm test hosts standalone under dotnet test (MTP)#55389

Draft
lewing wants to merge 6 commits into
mainfrom
lewing-blazor-dotnet-test-support
Draft

Run wasm test hosts standalone under dotnet test (MTP)#55389
lewing wants to merge 6 commits into
mainfrom
lewing-blazor-dotnet-test-support

Conversation

@lewing

@lewing lewing commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

Foundational SDK work for Blazor dotnet test support (#54091): run wasm test hosts (browser-wasm / wasi-wasm) under dotnet test and report their results, instead of crashing with PlatformNotSupportedException.

The exit-code path works on wasm today (validated end-to-end). The richer per-test TRX path is in place but gated off, pending a Microsoft.Testing.Extensions.TrxReport single-threaded-wasm fix — flipping one flag lights it up. Kept draft until that lands.

What it does

For wasm modules (detected via RunProperties.RuntimeIdentifier starting with browser/wasi):

  • Skips MTP server mode (--server dotnettestcli --dotnet-test-pipe). wasm sandboxes can't open a named pipe, so testfx's DotnetTestConnection otherwise throws PlatformNotSupportedException at host build. The host now runs standalone and reports via process exit code.
  • Reports pass/fail from the exit code (AssemblyRunStarted + AssemblyRunCompleted) — no per-test detail without a pipe or TRX, but correct assembly-level results.
  • Does NOT emit --report-trx (see Known limitation). A WasmReportTrxSupported flag gates the TRX emission + results-directory defaulting; a nullable TRX path in ReportStandaloneResults distinguishes "TRX not requested → exit-code result" from "TRX requested but missing → crash".

The per-test TRX machinery (TrxTestResultParser + the TRX replay in ReportStandaloneResults) is retained as forward-looking: flipping WasmReportTrxSupported once TrxReport supports wasm enables full per-test reporting with no other change.

Validated end-to-end

Booting a real browser-wasm MSTest host (net10, MSTest 4.4.0-preview) under node:

Test run summary: Passed!
  total: 3, succeeded: 2, skipped: 1   ->  exit 0

The SDK↔bridge contract was also validated against the real Blazor Gateway (dotnet/aspnetcore) over real sockets + headless Chrome (serve, browser launch, console streaming, exit code).

Known limitation — TRX path gated off on wasm (why this stays draft)

--report-trx currently crashes a wasm host: TrxReport's TrxResultStreamingStore ctor starts a background Thread (SystemTask.RunLongRunning, [UnsupportedOSPlatform("browser")]), which throws PlatformNotSupportedException on single-threaded wasm (confirmed on MSTest 4.4.0-preview.26367.7 / MTP 2.4.0-preview.26367.7):

System.Threading.Thread.ThrowIfNoThreadStart
System.Threading.Thread.Start
Microsoft.Testing.Extensions.TrxReport.Abstractions.Streaming.TrxResultStreamingStore..ctor

The MTP owners (testfx) confirmed this is a real, previously-untested bug and are implementing an inline-writer fallback for single-threaded wasm on the browser-enablement track. Until that ships, emitting --report-trx would regress a wasm run that passes on its exit code, so this PR gates it off (WasmReportTrxSupported = false) and relies on the exit code. The Blazor Gateway's TRX-POST path is blocked by the same bug — a shared dependency.

When the TrxReport fix ships: flip WasmReportTrxSupported (or gate on a detected TrxReport version / host capability) and the full per-test TRX path activates.

Scoping findings for #54091

  • A "properly configured" browser-wasm test project needs the net10-band wasm packs (the net11 preview.5 WasmGenerateAppBundle target is an empty stub), MSTest 4.4.0-preview+ (browser guards for the MSBuild + RunSettings host-controller extensions; 4.3.2 crashes), and a browser-compatible test registration. That configuration belongs in the Blazor/wasm test SDK or template, not the dotnet test CLI.
  • The CLI change itself is TFM-agnostic and small.

Tests (all passing)

  • TestApplicationTests — wasm RID detection; host-mode arg emission for standalone-with-TRX, standalone-without-TRX (neither server nor TRX), and non-standalone (server); results-directory defaulting.
  • TrxTestResultParserTests — TRX parse, missing/malformed handling, outcome mapping.
  • StandaloneTestResultReportingTests — TRX replay; missing-requested-TRX → handshake-failure (crash); no-TRX-requested → exit-code reporting (not a crash).
  • Existing TestApplicationHandlerTests unaffected.

Notes

  • User-visible: wasm test hosts run under dotnet test (exit-code results today; per-test TRX results once TrxReport supports wasm) instead of erroring with PlatformNotSupportedException.
  • No change to any non-wasm runtime identifier.
  • The browser-fronted (Blazor Gateway) path and the V2 live-streaming transport (testfx StreamMessageClient seam over a duplex Stream) are tracked separately under Blazor support for dotnet test #54091.

Microsoft.Testing.Platform's server mode (`--server dotnettestcli
--dotnet-test-pipe`) makes the test host connect back to a named pipe.
wasm runtimes (browser/wasi) can't open a named pipe, so requesting
server mode makes the host throw PlatformNotSupportedException before
any test runs (testfx `DotnetTestConnection` hard-instantiates a
`NamedPipeClient`).

For wasm modules, skip the server-mode options and the (unused) named
pipe listener, and let the host run standalone. Results come from the
process exit code and streamed stdout. The existing
`MissingTestSessionEnd` guard is already safe for a pipe-less run (its
per-session dictionary stays empty).

This is step 1 of Blazor `dotnet test` support (#54091):
it unblocks running browser-wasm/wasi-wasm MTP test hosts under node /
wasmtime today. Rich per-test reporting (TRX-based V1, or the live pipe
via a bridge host such as the Blazor Gateway) builds on top.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a8f2c5b-02e3-4963-926b-69266d1556e2
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
2 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

lewing and others added 5 commits July 20, 2026 17:41
Standalone wasm test hosts have no live pipe, so dotnet test needs an
on-disk TRX to recover results. For wasm modules, GetArguments now:

- always sets --results-directory (defaulting to <cwd>/TestResults when
  the user didn't pass one) so the host that writes the TRX and the
  tooling that reads it back agree on the location, and
- requests --report-trx --report-trx-filename blazor-wasm.trx.

Non-wasm runs are unchanged: --results-directory is still only set when
explicitly requested, and the server-mode pipe options are unchanged.

This is the launch contract a bridge host (e.g. the Blazor Gateway)
relies on to activate its TRX-collection path and copy the artifact back
to the results directory. The mode-dependent argument logic is extracted
into GetHostModeArguments / GetStandaloneResultsDirectory and unit
tested. Assumes the test project includes the
Microsoft.Testing.Extensions.TrxReport extension (MSTest.Sdk default).

Part of Blazor `dotnet test` support (#54091).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a8f2c5b-02e3-4963-926b-69266d1556e2
A standalone wasm test host has no live pipe, so its results never reach
the terminal reporter — OnTestProcessExited would route even a passing
run to HandshakeFailure. This reads the TRX the host wrote and replays it
into the reporter as if it had arrived over the pipe.

- TrxTestResultParser parses the MTP TRX (Microsoft.Testing.Extensions
  .TrxReport) into per-test results + run outcome.
- TestApplicationHandler.ReportStandaloneResults synthesizes an
  execution/instance id and drives AssemblyRunStarted -> TestCompleted
  (per result) -> AssemblyRunCompleted. A missing TRX is treated as a
  crash (TRX present => ran to completion) and falls back to
  HandshakeFailure with the process output and exit code.
- TestApplication routes wasm modules to this path after the host exits.

Outcome mapping follows MTP's TRX (Passed/Failed/NotExecuted; unknown
values treated as failures). TRX has no structured expected/actual (only
inline in the message), so those are passed as null; the structured diff
remains a V2/pipe capability.

Adds unit tests for the parser, the outcome mapping, and the handler
reporting path (results rendered vs. handshake-failure fallback).

Part of Blazor `dotnet test` support (#54091).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a8f2c5b-02e3-4963-926b-69266d1556e2
End-to-end validation of the wasm path (running a browser-wasm MSTest
host under node) showed that the exit-code result works, but requesting
`--report-trx` crashes a wasm host: TrxReport's TrxResultStreamingStore
starts a background Thread, which throws PlatformNotSupportedException on
single-threaded wasm (MSTest 4.4.0-preview / MTP 2.4.0-preview).

Add a code note at the `--report-trx` emission site so the dependency on
a TrxReport single-threaded-wasm guard is explicit, and a follow-up can
gate the emission on that fix so exit-code-passing wasm runs are not
regressed.

Part of Blazor `dotnet test` support (#54091).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a8f2c5b-02e3-4963-926b-69266d1556e2
End-to-end testing showed `--report-trx` crashes a standalone wasm host:
TrxReport's TrxResultStreamingStore starts a background thread in its
ctor (SystemTask.RunLongRunning, [UnsupportedOSPlatform("browser")]),
which throws PlatformNotSupportedException on single-threaded wasm. So
emitting `--report-trx` regresses a wasm run that otherwise passes on its
exit code. The MTP owners (testfx) confirmed the bug and recommended
withholding `--report-trx` for wasm until they guard that thread.

Introduce WasmReportTrxSupported (false today) gating:
- whether `--report-trx`/`--report-trx-filename` is emitted for wasm, and
- whether a results directory is defaulted for the wasm host.

ReportStandaloneResults now takes a nullable TRX path: null (TRX not
requested) reports assembly-level pass/fail from the exit code instead of
a handshake failure; a requested-but-missing TRX still means a crash.

The TRX reader (TrxTestResultParser / the per-test replay) stays in place
as forward-looking: flipping WasmReportTrxSupported (once the TrxReport
single-threaded-wasm guard ships in a Microsoft.Testing.Extensions
.TrxReport release) lights up the full per-test TRX path. Exit-code
results work on wasm today.

Tests updated for the new GetHostModeArguments/ReportStandaloneResults
shapes, plus a gated-off (no server, no TRX) case and a no-TRX-requested
exit-code reporting case. 51 tests green.

Part of Blazor `dotnet test` support (#54091).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a8f2c5b-02e3-4963-926b-69266d1556e2
…ests

Add a regression fixture using a verbatim TRX captured from a real
browser-wasm MSTest run (net10.0|browser-wasm, MSTest 4.4.0 with the
Microsoft.Testing.Extensions.TrxReport synchronous-on-wasm streaming
store), driven end-to-end through headless Chrome. This is the exact
schema the SDK reads back from a standalone wasm test host once
WasmReportTrxSupported is enabled, so the test exercises the full MTP
shape the parser must tolerate: TestDefinitions/TestEntries/TestLists,
the extra UnitTestResult attributes (executionId, testType, ...), a
UTF-8 BOM, and a NotExecuted result whose duration is present-but-zero.

Verified the parser surfaces the three per-test results, their outcomes,
and the run outcome, and that ToOutcome maps them to Passed/Passed/Skipped.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a8f2c5b-02e3-4963-926b-69266d1556e2
@Evangelink

Copy link
Copy Markdown
Member

Note

This review comment was AI-assisted and manually requested after validating the findings against the current SDK and testfx code.

I found four issues in the current shape:

  1. A passing exit-code-only wasm run becomes ZeroTests (exit code 8). ReportStandaloneResults(..., trxFilePath: null, ...) starts/completes an assembly but emits no TestCompleted event, so TerminalTestReporter.TotalTests stays zero. MicrosoftTestingPlatformTestCommand then replaces the successful aggregate result with ExitCode.ZeroTests. The added test only checks HasHandshakeFailure, not the final command result, so it misses this.

  2. --list-tests and extension help cannot work standalone. LaunchTestHostStandalone is selected solely from the RID, including discovery/help invocations, but those modes currently deliver DiscoveredTestMessages / CommandLineOptionMessages through the pipe. Successful standalone stdout is not rendered, so discovery produces no tests and extension help disappears.

  3. Standalone stdout/stderr is neither streamed nor bounded. Without a handshake, _protocolNegotiated remains false. ProcessOutputCollector.AddLine therefore never enters the branch that both writes output and calls TrimToBoundedTail; a verbose run retains all lines in memory and then discards successful output. This does not match the new “streamed stdout tail” comment.

  4. The gated TRX path has a shared-file race/staleness issue. Every standalone module uses TestResults/blazor-wasm.trx, while modules run concurrently by default. They can overwrite/read one another's file, and a valid file from an earlier run can be replayed when the current host fails before writing. The filename should be unique per TestApplication (and ideally an old destination should be removed before launch).

On moving this into MTP core: detecting wasm in the existing handshake is too late to choose the transport. The SDK must decide whether to create a pipe and append --server dotnettestcli --dotnet-test-pipe ... before launching the host; MTP selects DotnetTestConnection from those arguments, constructs the named-pipe client, and only then sends the handshake over that pipe. This is a bootstrap/chicken-and-egg problem. Adding RuntimeIdentifier or transport capabilities to that handshake is still useful for validation and future negotiation, but it cannot rescue a transport that cannot connect.

I think the blocking points can be fixed cleanly if testfx exposes the decision before the handshake:

  • Add an explicit launch contract such as --dotnet-test-transport pipe|stdio|websocket plus its endpoint/token, rather than having SDK infer behavior only from RID. MTP can reject unsupported combinations early (pipe on browser/WASI) and instantiate the right transport.
  • Alternatively/additionally, export a build-time MSBuild property/item from the test application describing supported transports/capabilities. The SDK already reads RunProperties before launch, so it can choose without executing a probe. This also avoids hard-coding wasm forever and supports bridge hosts.
  • Keep the existing versioned dotnettestcli message contract where possible. Pipe is the transport; JSON-RPC is a different protocol and does not itself solve browser IPC. For a real browser, a loopback HTTP host plus a browser-initiated authenticated WebSocket is the natural duplex transport; for WASI/headless Node, stdio plus exit code (or framed structured stdout) is simpler. HTTP POST alone works for final TRX/artifact upload but not live results, discovery, or cancellation.
  • Once connected over any usable transport, extend the handshake with RuntimeIdentifier and advertised transport/control capabilities so both sides can validate the selected mode and negotiate optional behavior.

This would also let this PR use a temporary standalone fallback without making RID detection the permanent orchestration contract.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants