Skip to content

Consume FSAC test hierarchy and enable MTP tests - #2180

Open
shayanhabibi wants to merge 7 commits into
ionide:mainfrom
shayanhabibi:feature/mtp-server-mode
Open

shayanhabibi wants to merge 7 commits into
ionide:mainfrom
shayanhabibi:feature/mtp-server-mode

Conversation

@shayanhabibi

@shayanhabibi shayanhabibi commented Sep 23, 2026 •

Copy link
Copy Markdown

Problem

Ionide's test explorer infers grouping nodes by splitting test names and selects tests with VSTest filter expressions. FSAC's new test tree and Microsoft.Testing.Platform (MTP) path need the extension to consume explicit parent links and select MTP tests by UID.

Change

  • Add the off-by-default FSharp.enableTestingPlatform setting and pass it to FSAC.
  • Build explorer nodes from the IDs, parent IDs, and leaf markers FSAC reports. Keep the existing TRX and AST name inference paths.
  • Preserve MTP test UIDs through discovery and send selected leaf UIDs on run requests, including when a grouping node is selected. Continue sending VSTest filters for VSTest projects.

Depends on ionide/FsAutoComplete#1552. Tracks #2069.

Validation

  • Fresh Fable compilation and development webpack bundle succeeded.
  • The paired FSAC branch passed 95 core test-explorer tests. Its LSP TestExplorerTests passed on net8.0 (30 passed, 2 ignored), net9.0 (32 passed), and net10.0 (32 passed) across both compiler modes.
  • A development-host run against the test-explorer regression workspace discovered and ran the MTP xUnit bridge project, with all four tests passing. An isolated VS Code extension-host probe invoked the Test Explorer Debug action for a selected MTP test, attached CoreCLR, hit the breakpoint at Tests.fs:6, and completed the command after continuing.

This remains a draft pending CI and review; GitHub currently reports no checks for the fork PR. UID-based MTP selection is implemented. The sample xUnit adapter rejects MTP graph-filter requests, so arbitrary graph expressions are outside this PR.

FsAutoComplete now reports grouping nodes and parent ids, and normalises
parameterised test names itself. Rebuilding the tree here by splitting
fully-qualified names would disagree with the server as soon as a project
runs under Microsoft.Testing Platform, whose nodes carry parent ids the
names do not encode.

TestItemDTO carries Id, ParentId and IsLeaf, and discovery builds its
hierarchy by following those links. getFullname_withNestedParamTests is
gone: the server's FullName already distinguishes theory cases, so applying
the same rule here appended the case parameters twice.

Name inference stays for the TRX and AST paths, which report names alone.
Exposes the FSAC flag that routes projects setting
IsTestingPlatformApplication through the Microsoft.Testing.Platform server
protocol. Off by default.
Microsoft.Testing.Platform runs a test named by the opaque uid it was
discovered under, rather than by a filter expression over its name, so a test
carries that uid from discovery through to the run.

A grouping node holds no uid of its own, so selecting one is read as the
runnable tests beneath it. A test discovered through VSTest carries no uid and
is still named by the filter expression, which is sent alongside.

@shayanhabibi shayanhabibi left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open question: Should we discriminate between DTOs targeting VSTest/MTP instead of optional fields

@shayanhabibi
shayanhabibi marked this pull request as ready for review September 24, 2026 05:46
@shayanhabibi

Copy link
Copy Markdown
Author

@farlee2121

See the other PR for the heart of the implementation.

I do want to pursue some cleanup of Ionide settings (ie grouping them) and other facets, but this is the foundational work.

Open Question: Should I continue adding these other facets, and we cherry pick them into separate PRs later/just merge them all later?

@farlee2121

Copy link
Copy Markdown
Contributor

Unique ids

I haven't gotten to look closely at this, but I see one item that concerns me

Should we discriminate between DTOs targeting VSTest/MTP instead of optional fields

Preserve MTP test UIDs through discovery and send selected leaf UIDs on run requests, including when a grouping node is selected. Continue sending VSTest filters for VSTest projects.

This sounds like VSTest and MTP behave in different and incompatible ways through the same api, causing a leaky abstraction that requires consumers to understand how certain calls will behave based on implicit context.

Is there a way we can bridge the gap? Have unique identifiers for both MTP and VSTest?

I notice VsTestConsoleWrapper.RunTests can run a sequence of TestCases, and a TestCase can be constructed with FullyQualifiedName, ExecutorUri, and Source. To my understanding, that combination should be unique. Perhaps we can package these into a unique identifier. Then both VSTest and MTP could run specific tests by an "id". The id is provided by the server and interpreted by the server and treated as a black box by the client.

Consumers could get themselves into trouble by trying to parse out id components, but that'd be unsupported shenanigans requiring some reverse engineering.

Unique Ids and Test Filters

Related. What happens if both test ids and a filter are specified in the run request?
Those seem like mutually exclusive parameters.

Union types don't properly exist over process boundaries. We could throw an error if both are specified. In any case, it's a behavior we should think about.

@shayanhabibi

Copy link
Copy Markdown
Author

Things get really finicky as we start to treat one field as another. Strictly speaking, display names are not unique ids. The default filter works on display names, not unique ids. There is a separate filter argument in MTP for specifying unique ids.

For us in FSharp, where our tests are constructed as values instead of associated with types/members, we essentially treat them the same.

So I don't disagree with you at all - yeah I'll backport VSTest API to fill fields that are required in MTP so we can remove fields that are optional only because one API uses it - however, we should be careful when communicating how filters work (as you point out).

I don't actually know if MTP allows you to specify uid filters with normal name filters. Something I'll look into. But - we should be able to request both.

I'll report back after review

@farlee2121

farlee2121 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

I think I didn't correctly communicate my full idea.

I didn't mean to suggest stuffing unique ids into the filter or filters into unique ids. I would expect filter and id-based runs to be distinct.

I'm suggesting that both filters and id-based runs are available for both MTP and VSTest, and work consistently between the two. All tests could be returned from FSAC with a unique id. FSAC could service run requests by these ids using any underlying platform.

  • For MTP, they already have proper dedicated ids so we use those ids and underlying apis
  • For VSTest, VSTest exposes two overloads of RunTests. The one we currently use runs the whole project. The other accepts a list of specific TestCase objects to run. These TestCases have no Id, but the combination of members is unique (or vstest throws an error). So, we could possibly package these members into a string in a standard format to act as a unique identifier. Then, when the id is passed back to the server, the server can unpack the values back into a TestCase to run the specific tests. The client is indifferent to the format of the id, and the FSAC consumer experience is uniform between MTP and VSTest

UPDATE: I looked at the VSTest code for TestCase, and they're actually constructing a Guid based on the three TestCase members for internal use as a unique id. So I think we'd be pretty safe assuming the combo of FullyQualifiedName, ExecutorUri, and Source can act as an id.

// Part of TestCase
	private Guid GetTestId()
	{
		string text = Source;
		try {
			text = Path.GetFileName(text);
		} catch {
		}
		string text2 = ExecutorUri?.ToString() + text;
		text2 += GetFullyQualifiedName();
		return EqtHash.GuidFromString(text2);
	}

FsAutoComplete now gives every test an opaque id that routes it to its
project, target framework and platform, and runs a selection named by those
ids. A test explorer item keeps the id FSAC reported for it, and a run sends
the ids of the tests under the selection and nothing else: FSAC rejects ids
sent together with a filter expression, and works out the projects to run
from the ids themselves. The filter expression stays with the dotnet CLI path.

A selected test FSAC did not report, such as one found only in code, has no
id. Discovery runs once to find it, and a test still without one is marked
errored instead of widening the run.

Results and started notifications are matched to the explorer by that id,
so tests that share a name within a project, such as theory rows run on
Microsoft.Testing.Platform, no longer collide. The name-based id is only a
fallback for items and results that carry no id.

PlatformUid is gone from TestItemDTO, and TestUids is now TestIds.
An explorer item is still named by its project and full name, and FSAC
does not keep full names unique. A grouping can share its name with a
test, and on Microsoft.Testing.Platform, where the full name is the
display name, several tests can share one. Siblings that share a name got
one explorer id, so the item added last replaced the others: a grouping
hid the test of its name, and only one of several same-named tests stayed
in the tree. The previous commit matched results by FSAC's id, but the
tree still collapsed these tests, so they did not stay apart as it said.

A grouping is now shown as one node with the test of its name, and that
test runs along with the tests under it. Each further test of a name gets
a numbered name. Selections and missing results are told apart by FSAC's
id where there is one. Before, tests of one name under different parents
were deduplicated to one, so the others were never sent to FSAC.
@shayanhabibi

Copy link
Copy Markdown
Author

@farlee2121 Gotcha, that makes sense - I've reworked it so every test FSAC hands out gets an opaque id, and id runs and filter runs are separate paths that behave the same on VSTest and MTP.

The VSTest triple

I tried the FullyQualifiedName + ExecutorUri + Source approach first, but it doesn't hold up by itself:

  • Not unique for parameterised tests. Every xUnit [<Theory>] row and MSTest DataRow shares one FQN, so all rows collapse to the same triple (and the same GetTestId hash).
  • A TestCase rebuilt from the triple doesn't run everywhere. Ran discovery then RunTests(IEnumerable<TestCase>) against real projects:
Adapter Rebuilt TestCase runs? Needs
NUnit (adapter 5.1) ✅ -
Expecto (YoloDev 0.14) ✅ -
xUnit v2 (runner 2.8) ❌ XunitTestCase serialized property (~2.6 KB)
xUnit v2 on runner 3.x / xUnit v3 ❌ XunitTestCaseUniqueID + XunitTestCaseSerialization (~4 KB)
MSTest 3.6 ❌ MSTestDiscoverer.TestClassName, plus DynamicData per row

xUnit and MSTest set their own TestCase.Id (unique per row) rather than the triple hash. When an adapter doesn't set one, TestCase.Id falls back to the GetTestId hash you found - so NUnit/Expecto end up with your triple anyway.

What I've got now

  • Id: t1|<kind>|<project>|<tfm>|<key>, escaped. kind is vs, mtp or grp; the key is the adapter's TestCase.Id for VSTest, and the node uid for MTP. Clients treat it as a black box - server only uses it to route back to the right project.
  • VSTest id runs: rediscover just the sources the ids point at, pick out the matching TestCases, and RunTests(IEnumerable<TestCase>) (or RunTestsWithCustomTestHost when debugging). Adapters get back exactly what they produced, and there's no server-side cache to go stale. If the extra discovery pass hurts we can cache off the binary timestamp later.
  • MTP id runs: each app only gets its own uids; apps with nothing selected aren't launched at all (previously every MTP app got every uid).
  • Request: TestIds replaces TestUids, and PlatformUid is gone from the item.
    • TestIds + TestCaseFilter together -> rejected with InvalidParams. Same for malformed ids, group ids, unknown projects, and ids outside LimitToProjects - never quietly widened into a run-all.
    • Empty TestIds runs nothing.
    • Ionide expands a selected group into its leaves.
  • Filters: still VSTest syntax, and still rejected when MTP projects are in scope. MTP filtering can be its own PR imo.

Side effect: two existing bugs fell out - duplicate display names were being merged (dropping tests), and a leaf with the same name as a group was dropping the group.

Got this locally across both repos; I'll push once I've smoke tested the explorer in VS Code.

Editing a test file merged the new code locations into the explorer by
adding every test whose line had moved. Adding replaces the item of the
same id, and a test found in code carries no FSAC id and none of the
children FSAC reported under it, such as theory rows. An edited test so
lost its id and could no longer be run: it was "not found by test
discovery".

A test from code that is already in the tree now has its range updated in
place, and its children are merged the same way. Tests new to the tree are
still added and tests gone from the code are still removed.
Refresh builds only the projects it takes for test projects, and it knew
them by the VSTest packages alone. A project that opts into
Microsoft.Testing.Platform, such as one on xunit.v3 or MSTest.Sdk, need
not reference those packages, so it was never built and FSAC found no
tests in it.

FSAC does not pass on IsTestingPlatformApplication, so such a project is
now told by the Microsoft.Testing.Platform package its runner resolves,
or by the IsTestProject property FSAC does report.
@shayanhabibi

shayanhabibi commented Sep 25, 2026 •

Copy link
Copy Markdown
Author

@farlee2121 Pushed - smoke tested the explorer in VS Code against a mixed VSTest + MTP workspace and it all behaves. Plain-terms rundown of where it's at:

The id rework (what you asked for)

  • Every test gets an id from FSAC. Ionide doesn't look inside it - it just hands it back when you hit run. Same deal whether the test lives in a VSTest or an MTP project.
  • Running by id and running by filter are separate. Send ids or a filter, not both. Anything off (both at once, a garbled id, a group id, an id for a project that isn't loaded or is outside LimitToProjects, an id for the wrong platform, a filter with MTP projects in scope) gets rejected with InvalidParams rather than quietly turning into a run-all.
  • Empty id list runs nothing. Each MTP app only gets launched with its own tests; apps with nothing selected aren't started at all.
  • Tests that don't come back get flagged. If you ask for an id and no result shows up, you get a warning naming it. If the run itself falls over, you get InternalError.
  • Ionide side: picking a group runs its leaves by id. Two tests with the same name (e.g. Tests.My test and Other.My test) now show up and report separately instead of one swallowing the other.

Fixes since the last push

Did an adversarial review pass over both PRs and fixed the bigger things it turned up:

  • Editing a test file no longer breaks running it. Before, an edit swapped the explorer item for a fresh one without its FSAC id, so running a theory right after an edit failed with "not found by test discovery". Items are now updated in place and keep their id and rows.
  • Refresh now builds MTP projects. It only recognised test projects by the VSTest packages, so an xunit.v3 / MSTest-runner project never got built. It now also counts projects that pull in Microsoft.Testing.Platform. (FSAC doesn't send IsTestingPlatformApplication to the extension yet - that'd be the cleaner signal, happy to add it if you'd rather.)
  • FSAC side (details on Add Microsoft.Testing.Platform server-mode test support FsAutoComplete#1552): one broken or unbuilt MTP project no longer takes down discovery/runs for everything else, and MTP tests from frameworks other than xUnit now get a proper Namespace.Class.Method tree instead of piling up at the project root.

Still open / heads up

  • Needs an FSAC release with update FSAC, purge old mono-referencing options, update options texts #1552 before this can merge - the extension relies on the new ids.
  • MTP filtering is still finicky - own PR imo, don't want the PR to blow out (I can make a PR that is off this branch though).
  • xunit.v3 only hands console output over when the test assembly opts in with [<assembly: CaptureConsole>], so "no output" on an MTP test without that is expected.

Would appreciate another look when you get a chance 🙏

@farlee2121

Copy link
Copy Markdown
Contributor

Looking good. Just one rename suggestion and a few spots I'll want to run the regression tests on.
I haven't gotten through all of the FSAC changes yet, but looking pretty clean thus far

@shayanhabibi

Copy link
Copy Markdown
Author

Looking good. Just one rename suggestion and a few spots I'll want to run the regression tests on. I haven't gotten through all of the FSAC changes yet, but looking pretty clean thus far

Let me know; I did run against your regression library earlier in the dev which brought edge cases up. Maybe we should include them as a suite in FSAC?

@farlee2121

Copy link
Copy Markdown
Contributor

Manual Testing Results

I found a few unexpected behaviors while testing

  • MTP projects aren't getting live test explorer updates as the code changes (i.e. renaming a test)
  • NUnit with MTP wasn't getting location-based features like "go to test". I confirmed the same project has code locations in Visual Studio. I added a new MTPv2.NUnit.Tests project to the regression test repo.
  • TUnit parameterized tests aren't grouped
  • Across mtp-based test frameworks, tests with + or . in the name are being split. Confirmed that visual studio is now handling these correctly (except for NUnit), so I'd guess they're coming back from the framework with the expected parent ids.

Heads up that MTPv2.MSTest.Tests has some odd behavior. Visual Studio won't run it if <EnableMSTestRunner> is set but dotnet test won't run it if that same property isn't set.

MTP filters

Regarding filters for MTP. I think it's fine if we delay that feature.

I found this suggestion that MSTest or NUnit might have a filter converter

VSTestBridge has some very basic support for filter translation. But really only for Uid-based test filtering

The NUnit filter conversion is to their own filter system.

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