From e94eaf573f9b799a7462c311a62a7a144b61130d Mon Sep 17 00:00:00 2001 From: shayanhabibi Date: Wed, 23 Sep 2026 00:10:51 +0800 Subject: [PATCH 1/7] Take the test tree from FSAC instead of inferring it from names 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. --- src/Components/TestExplorer.fs | 45 ++++++++++++++-------------------- src/Core/DTO.fs | 6 +++++ 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/Components/TestExplorer.fs b/src/Components/TestExplorer.fs index 4d0a7ee9c..9c3fe44be 100644 --- a/src/Components/TestExplorer.fs +++ b/src/Components/TestExplorer.fs @@ -269,26 +269,6 @@ module TestFrameworkId = else None -module TestItemDTO = - let getFullname_withNestedParamTests (dto: TestItemDTO) = - match dto.ExecutorUri |> TestFrameworkId.tryFromExecutorUri with - // NOTE: XUnit and MSTest don't include the theory case parameters in the FullyQualifiedName, but do include them in the DisplayName. - // Thus we need to append the DisplayName to differentiate the test cases - | Some TestFrameworkId.MsTest -> - if dto.FullName.EndsWith(dto.DisplayName) then - dto.FullName - else - dto.FullName + "." + dto.DisplayName - | Some TestFrameworkId.XUnit -> - // NOTE: XUnit includes the FullyQualifiedName in the DisplayName. - // But it doesn't nest theory cases, just appends the case parameters - if dto.DisplayName <> dto.FullName then - let theoryCaseFragment = dto.DisplayName.Split('.') |> Array.last - dto.FullName + "." + theoryCaseFragment - else - dto.FullName - | _ -> dto.FullName - type TestResult = { FullTestName: string Outcome: TestResultOutcome @@ -323,7 +303,7 @@ module TestResult = let ofTestResultDTO (testResultDto: TestResultDTO) : TestResult = let expected, actual = tryExtractExpectedAndActual testResultDto.ErrorMessage - { FullTestName = testResultDto.TestItem |> TestItemDTO.getFullname_withNestedParamTests + { FullTestName = testResultDto.TestItem.FullName Outcome = testResultDto.Outcome |> TestResultOutcome.ofOutcomeDto Output = testResultDto.AdditionalOutput ErrorMessage = testResultDto.ErrorMessage @@ -974,13 +954,24 @@ module TestItem = recurse hierarchy - let mapDtosForProject ((projectPath, targetFramework), flatTests) = - let testDtoToNamedItem (dto: TestItemDTO) = - {| Data = dto - FullName = dto |> TestItemDTO.getFullname_withNestedParamTests |} + /// The tree the server reported, linked through `ParentId`. + let hierarchyOfDtos (flatTests: TestItemDTO array) : TestName.NameHierarchy array = + let byParent = + flatTests |> Array.groupBy (fun dto -> dto.ParentId) |> Map.ofArray - let namedHierarchies = - flatTests |> Array.map testDtoToNamedItem |> TestName.inferHierarchy + let childrenOf parentId = + byParent |> Map.tryFind parentId |> Option.defaultValue [||] + + let rec build (dto: TestItemDTO) : TestName.NameHierarchy = + { TestName.NameHierarchy.Data = (if dto.IsLeaf then Some dto else None) + TestName.NameHierarchy.FullName = dto.FullName + TestName.NameHierarchy.Name = (TestName.splitSegments dto.FullName |> List.last).Text + TestName.NameHierarchy.Children = childrenOf (Some dto.Id) |> Array.map build } + + childrenOf None |> Array.map build + + let mapDtosForProject ((projectPath, targetFramework), flatTests) = + let namedHierarchies = hierarchyOfDtos flatTests let projectChildTestItems = namedHierarchies diff --git a/src/Core/DTO.fs b/src/Core/DTO.fs index ddac7126f..64d838f6f 100644 --- a/src/Core/DTO.fs +++ b/src/Core/DTO.fs @@ -372,6 +372,12 @@ module DTO = type TestItemDTO = { + /// Distinguishes this node from every other node reported by the server. + Id: string + /// The `Id` of the node one level up, or `None` at the root of a project. + ParentId: string option + /// A runnable test. `false` marks a grouping node. + IsLeaf: bool FullName: string DisplayName: string /// Identifies the test adapter that ran the tests From ca158418aca14bb7c75a8fcf348e0a650319c2b3 Mon Sep 17 00:00:00 2001 From: shayanhabibi Date: Wed, 23 Sep 2026 01:15:40 +0800 Subject: [PATCH 2/7] Add the FSharp.enableTestingPlatform setting Exposes the FSAC flag that routes projects setting IsTestingPlatformApplication through the Microsoft.Testing.Platform server protocol. Off by default. --- release/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/release/package.json b/release/package.json index 6581bea4e..a948ff60c 100644 --- a/release/package.json +++ b/release/package.json @@ -502,6 +502,11 @@ "description": "EXPERIMENTAL. Enables F# analyzers for custom code diagnostics. Requires restart.", "type": "boolean" }, + "FSharp.enableTestingPlatform": { + "default": false, + "description": "EXPERIMENTAL. Runs tests through the Microsoft.Testing.Platform server protocol for projects that set IsTestingPlatformApplication. Requires restart.", + "type": "boolean" + }, "FSharp.enableMSBuildProjectGraph": { "default": false, "description": "EXPERIMENTAL. Enables support for loading workspaces with MsBuild\u0027s ProjectGraph. This can improve load times. Requires restart.", From 8d7f1263fb03d426b953c13feefff25f81a6f8aa Mon Sep 17 00:00:00 2001 From: shayanhabibi Date: Wed, 23 Sep 2026 02:43:10 +0800 Subject: [PATCH 3/7] Name the tests to run by uid where the platform identifies them that way 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. --- src/Components/TestExplorer.fs | 73 ++++++++++++++++++++++++---------- src/Core/DTO.fs | 3 ++ src/Core/LanguageService.fs | 13 ++++-- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/src/Components/TestExplorer.fs b/src/Components/TestExplorer.fs index 9c3fe44be..82bceda96 100644 --- a/src/Components/TestExplorer.fs +++ b/src/Components/TestExplorer.fs @@ -222,6 +222,10 @@ type TestItem with member this.TestFramework: string = this?testFramework + /// Addresses this test to Microsoft.Testing.Platform. A grouping node and a test run under + /// VSTest carry `None`. + member this.PlatformUid: string option = this?platformUid |> Option.ofObj + [] type TestResultOutcome = | NotExecuted @@ -800,13 +804,18 @@ module TestItem = recurse root type TestItemBuilder = - { id: TestId - label: string - uri: Uri option - range: Vscode.Range option - children: TestItem array - // i.e. NUnit. Used for an Nunit-specific workaround - testFramework: TestFrameworkId option } + { + id: TestId + label: string + uri: Uri option + range: Vscode.Range option + children: TestItem array + // i.e. NUnit. Used for an Nunit-specific workaround + testFramework: TestFrameworkId option + /// Addresses the test to Microsoft.Testing.Platform, which names a test to run by uid + /// rather than by a filter expression. + platformUid: string option + } type TestItemFactory = TestItemBuilder -> TestItem @@ -824,6 +833,10 @@ module TestItem = | Some frameworkId -> testItem?testFramework <- frameworkId | None -> () + match builder.platformUid with + | Some uid -> testItem?platformUid <- uid + | None -> () + testItem factory @@ -845,7 +858,8 @@ module TestItem = uri = location |> LocationRecord.tryGetUri range = location |> LocationRecord.tryGetRange children = namedNode.Children |> Array.map recurse - testFramework = None } + testFramework = None + platformUid = None } recurse hierarchy @@ -881,7 +895,8 @@ module TestItem = uri = Some uri range = range children = t.childs |> Array.map (fun n -> recurse fullName (Some t.moduleType) n) - testFramework = t?``type`` } + testFramework = t?``type`` + platformUid = None } ti @@ -899,7 +914,8 @@ module TestItem = uri = None range = None children = children - testFramework = None } + testFramework = None + platformUid = None } let ofTestDTOs testItemFactory tryGetLocation (flatTests: TestItemDTO array) = @@ -950,14 +966,14 @@ module TestItem = children = namedNode.Children |> Array.map recurse testFramework = namedNode.Data - |> Option.bind (fun t -> t.ExecutorUri |> TestFrameworkId.tryFromExecutorUri) } + |> Option.bind (fun t -> t.ExecutorUri |> TestFrameworkId.tryFromExecutorUri) + platformUid = namedNode.Data |> Option.bind (fun dto -> dto.PlatformUid) } recurse hierarchy /// The tree the server reported, linked through `ParentId`. let hierarchyOfDtos (flatTests: TestItemDTO array) : TestName.NameHierarchy array = - let byParent = - flatTests |> Array.groupBy (fun dto -> dto.ParentId) |> Map.ofArray + let byParent = flatTests |> Array.groupBy (fun dto -> dto.ParentId) |> Map.ofArray let childrenOf parentId = byParent |> Map.tryFind parentId |> Option.defaultValue [||] @@ -1072,7 +1088,8 @@ module TestItem = uri = maybeLocation |> LocationRecord.tryGetUri range = maybeLocation |> LocationRecord.tryGetRange children = [||] - testFramework = None } + testFramework = None + platformUid = None } collection.add (testItem) @@ -1147,7 +1164,8 @@ module TestDiscovery = uri = withUri.uri range = withUri.range children = target.children.TestItems() - testFramework = withUri?testFramework } + testFramework = withUri?testFramework + platformUid = target.PlatformUid } (replacementItem, withUri) @@ -1299,7 +1317,8 @@ module TestDiscovery = let testItemFactory (testItemBuilder: TestItem.TestItemBuilder) = testItemFactory { testItemBuilder with - testFramework = detectedTestFramework } + testFramework = detectedTestFramework + platformUid = None } let testHierarchy = testNames @@ -1533,7 +1552,8 @@ module Interactions = let testItemFactory (ti: TestItem.TestItemBuilder) = testItemFactory { ti with - testFramework = testResult.TestFramework } + testFramework = testResult.TestFramework + platformUid = None } TestItem.getOrMakeHierarchyPath rootTestCollection @@ -1874,10 +1894,20 @@ module Interactions = let onAttachDebugger (processId: int) = VSCodeActions.launchDebugger (string processId) - let filterExpression, projectSubset = + /// Names the selected tests to Microsoft.Testing.Platform, which runs a test by + /// uid rather than by a filter expression. A grouping node holds no uid of its + /// own, so a selection is read as the runnable tests under it. + let testUids (selectedCases: TestItem seq) = + selectedCases + |> Array.ofSeq + |> TestItem.runnableFromArray + |> Array.choose (fun test -> test.PlatformUid) + |> Array.distinct + + let filterExpression, projectSubset, uids = match req.``include`` with - | None -> None, None - | Some selectedCases when Seq.isEmpty selectedCases -> None, None + | None -> None, None, None + | Some selectedCases when Seq.isEmpty selectedCases -> None, None, None | Some selectedCases -> let filter = selectedCases @@ -1893,7 +1923,7 @@ module Interactions = |> Array.ofSeq |> Some - filter, projectSubset + filter, projectSubset, Some(testUids selectedCases) logger.Debug($"Test Filter Expression: {filterExpression}") @@ -1905,6 +1935,7 @@ module Interactions = onAttachDebugger projectSubset filterExpression + uids shouldDebug mergeResults TrimMissing.Trim runResult.Data diff --git a/src/Core/DTO.fs b/src/Core/DTO.fs index 64d838f6f..a79a0f537 100644 --- a/src/Core/DTO.fs +++ b/src/Core/DTO.fs @@ -384,6 +384,9 @@ module DTO = /// Example: executor://xunit/VsTestRunner2/netcoreapp /// Used for determining the test library, which effects how tests names are broken down ExecutorUri: string + /// Addresses a runnable test to Microsoft.Testing.Platform, which identifies tests by + /// opaque uid. A grouping node and a test run under VSTest carry `None`. + PlatformUid: string option ProjectFilePath: string TargetFramework: string CodeFilePath: string option diff --git a/src/Core/LanguageService.fs b/src/Core/LanguageService.fs index 071e2149f..d480d5da9 100644 --- a/src/Core/LanguageService.fs +++ b/src/Core/LanguageService.fs @@ -95,9 +95,14 @@ module LanguageService = ``end``: Fable.Import.VSCode.Vscode.Position } type TestRunRequest = - { LimitToProjects: string array option - TestCaseFilter: string option - AttachDebugger: bool } + { + LimitToProjects: string array option + TestCaseFilter: string option + /// Names the tests to run on Microsoft.Testing.Platform, by the uid each was + /// discovered under. `None` runs every test of the projects being run. + TestUids: string array option + AttachDebugger: bool + } type Uri with @@ -622,6 +627,7 @@ Consider: (onAttachDebugger: ProcessId -> JS.Promise) (projectSubset: string array option) (testCaseFilter: string option) + (testUids: string array option) (attachDebugger: bool) = match client with @@ -646,6 +652,7 @@ Consider: let request: Types.TestRunRequest = { LimitToProjects = projectSubset TestCaseFilter = testCaseFilter + TestUids = testUids AttachDebugger = attachDebugger } cl.sendRequest ("test/runTests", request) From 87cbb5eea8f93021dbf16f8294a63f9f17dbd803 Mon Sep 17 00:00:00 2001 From: shayanhabibi Date: Fri, 25 Sep 2026 17:11:29 +0800 Subject: [PATCH 4/7] Run tests by the ids FSAC issued them 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. --- src/Components/TestExplorer.fs | 254 ++++++++++++++++++++------------- src/Core/DTO.fs | 6 +- src/Core/LanguageService.fs | 11 +- 3 files changed, 165 insertions(+), 106 deletions(-) diff --git a/src/Components/TestExplorer.fs b/src/Components/TestExplorer.fs index 82bceda96..9ff01c6a0 100644 --- a/src/Components/TestExplorer.fs +++ b/src/Components/TestExplorer.fs @@ -222,9 +222,10 @@ type TestItem with member this.TestFramework: string = this?testFramework - /// Addresses this test to Microsoft.Testing.Platform. A grouping node and a test run under - /// VSTest carry `None`. - member this.PlatformUid: string option = this?platformUid |> Option.ofObj + /// The opaque `Id` FSAC reported for this test, which names it in a run request and in its + /// results. `None` for a grouping node, whose id FSAC would not run, and for a test FSAC did + /// not report, such as one found only in code. + member this.ServerId: string option = this?serverId |> Option.ofObj [] type TestResultOutcome = @@ -274,17 +275,21 @@ module TestFrameworkId = None type TestResult = - { FullTestName: string - Outcome: TestResultOutcome - Output: string option - ErrorMessage: string option - ErrorStackTrace: string option - Expected: string option - Actual: string option - Timing: float - TestFramework: TestFrameworkId option - ProjectFilePath: ProjectFilePath - TargetFramework: TargetFramework } + { + /// The `Id` of the test FSAC reported this result for. `None` for results read from TRX. + ServerId: string option + FullTestName: string + Outcome: TestResultOutcome + Output: string option + ErrorMessage: string option + ErrorStackTrace: string option + Expected: string option + Actual: string option + Timing: float + TestFramework: TestFrameworkId option + ProjectFilePath: ProjectFilePath + TargetFramework: TargetFramework + } module TestResult = let tryExtractExpectedAndActual (message: string option) = @@ -307,7 +312,8 @@ module TestResult = let ofTestResultDTO (testResultDto: TestResultDTO) : TestResult = let expected, actual = tryExtractExpectedAndActual testResultDto.ErrorMessage - { FullTestName = testResultDto.TestItem.FullName + { ServerId = Some testResultDto.TestItem.Id + FullTestName = testResultDto.TestItem.FullName Outcome = testResultDto.Outcome |> TestResultOutcome.ofOutcomeDto Output = testResultDto.AdditionalOutput ErrorMessage = testResultDto.ErrorMessage @@ -790,6 +796,31 @@ module TestItem = // NOTE: there can be duplicates. i.e. if a child and parent are both selected in the explorer |> Array.distinctBy getId + /// Finds the item among `items` that something reported by FSAC refers to. The `Id` FSAC + /// reported decides when there is one; the name-based id is only used for items FSAC did not + /// report, or for reports that carry no `Id`. + let expectedLookup (items: TestItem array) (keysOf: 'a -> string option * TestId) : 'a -> TestItem option = + let byServerId = + items + |> Array.choose (fun t -> t.ServerId |> Option.map (fun id -> id, t)) + |> Map.ofArray + + let byName = items |> Array.map (fun t -> t.id, t) |> Map.ofArray + + let byNameWithoutServerId = + items + |> Array.filter (fun t -> Option.isNone t.ServerId) + |> Array.map (fun t -> t.id, t) + |> Map.ofArray + + fun reported -> + match keysOf reported with + | Some serverId, name -> + byServerId + |> Map.tryFind serverId + |> Option.orElseWith (fun () -> byNameWithoutServerId |> Map.tryFind name) + | None, name -> byName |> Map.tryFind name + let tryGetLocation (testItem: TestItem) = match testItem.uri, testItem.range with | Some uri, Some range -> Some(vscode.Location.Create(uri, !^range)) @@ -812,9 +843,8 @@ module TestItem = children: TestItem array // i.e. NUnit. Used for an Nunit-specific workaround testFramework: TestFrameworkId option - /// Addresses the test to Microsoft.Testing.Platform, which names a test to run by uid - /// rather than by a filter expression. - platformUid: string option + /// The `Id` FSAC reported for the test, if FSAC reported it and it is not a grouping. + serverId: string option } type TestItemFactory = TestItemBuilder -> TestItem @@ -833,8 +863,8 @@ module TestItem = | Some frameworkId -> testItem?testFramework <- frameworkId | None -> () - match builder.platformUid with - | Some uid -> testItem?platformUid <- uid + match builder.serverId with + | Some id -> testItem?serverId <- id | None -> () testItem @@ -859,7 +889,7 @@ module TestItem = range = location |> LocationRecord.tryGetRange children = namedNode.Children |> Array.map recurse testFramework = None - platformUid = None } + serverId = None } recurse hierarchy @@ -896,7 +926,7 @@ module TestItem = range = range children = t.childs |> Array.map (fun n -> recurse fullName (Some t.moduleType) n) testFramework = t?``type`` - platformUid = None } + serverId = None } ti @@ -915,7 +945,7 @@ module TestItem = range = None children = children testFramework = None - platformUid = None } + serverId = None } let ofTestDTOs testItemFactory tryGetLocation (flatTests: TestItemDTO array) = @@ -967,7 +997,7 @@ module TestItem = testFramework = namedNode.Data |> Option.bind (fun t -> t.ExecutorUri |> TestFrameworkId.tryFromExecutorUri) - platformUid = namedNode.Data |> Option.bind (fun dto -> dto.PlatformUid) } + serverId = namedNode.Data |> Option.map (fun dto -> dto.Id) } recurse hierarchy @@ -1089,7 +1119,7 @@ module TestItem = range = maybeLocation |> LocationRecord.tryGetRange children = [||] testFramework = None - platformUid = None } + serverId = None } collection.add (testItem) @@ -1165,7 +1195,7 @@ module TestDiscovery = range = withUri.range children = target.children.TestItems() testFramework = withUri?testFramework - platformUid = target.PlatformUid } + serverId = target.ServerId } (replacementItem, withUri) @@ -1318,7 +1348,7 @@ module TestDiscovery = testItemFactory { testItemBuilder with testFramework = detectedTestFramework - platformUid = None } + serverId = None } let testHierarchy = testNames @@ -1553,7 +1583,7 @@ module Interactions = testItemFactory { ti with testFramework = testResult.TestFramework - platformUid = None } + serverId = None } TestItem.getOrMakeHierarchyPath rootTestCollection @@ -1563,13 +1593,23 @@ module Interactions = testResult.TargetFramework testResult.FullTestName - let treeItemComparable (t: TestItem) = TestItem.getId t + let tryFindExpected = + TestItem.expectedLookup expectedToRun (fun (r: TestResult) -> + r.ServerId, TestItem.constructId r.ProjectFilePath r.FullTestName) + + let matched, added = + testResults + |> Array.map (fun r -> tryFindExpected r, r) + |> Array.partition (fst >> Option.isSome) - let resultComparable (r: TestResult) = - TestItem.constructId r.ProjectFilePath r.FullTestName + let expected = matched |> Array.map (fun (t, r) -> t.Value, r) + let added = added |> Array.map snd - let missing, expected, added = - ArrayExt.venn treeItemComparable resultComparable expectedToRun testResults + let matchedIds = expected |> Array.map (fst >> TestItem.getId) |> Set.ofArray + + let missing = + expectedToRun + |> Array.filter (fun t -> not (matchedIds.Contains(TestItem.getId t))) expected |> Array.iter (displayTestResultInExplorer testRun) @@ -1591,7 +1631,8 @@ module Interactions = let expected, actual = TestResult.tryExtractExpectedAndActual trxResult.UnitTestResult.Output.ErrorInfo.Message - { FullTestName = trxResult.UnitTest.FullName + { ServerId = None + FullTestName = trxResult.UnitTest.FullName Outcome = !!trxResult.UnitTestResult.Outcome Output = trxResult.UnitTestResult.Output.StdOut ErrorMessage = trxResult.UnitTestResult.Output.ErrorInfo.Message @@ -1818,21 +1859,70 @@ module Interactions = |> ignore } + /// The tests a selection names to FSAC, by the `Id` FSAC reported for each. A grouping node + /// is read as the runnable tests under it. A test FSAC did not report, such as one found only + /// in code, has no `Id`: discovery is run once to find it, and a test still without one is + /// marked errored rather than widening the run. + let private resolveSelectedTestIds + (rediscover: unit -> JS.Promise) + (rootTestCollection: TestItemCollection) + (testRun: TestRun) + (selectedCases: TestItem array) + = + promise { + let selectedTests = selectedCases |> TestItem.runnableFromArray + + let! selectedTests = + if selectedTests |> Array.forall (fun t -> Option.isSome t.ServerId) then + Promise.lift selectedTests + else + promise { + do! rediscover () + let discovered = rootTestCollection.TestItems() + + // Discovery replaces the items in the tree, so every selected test is looked up again. + return + selectedTests + |> Array.map (fun t -> TestItem.tryGetById t.id discovered |> Option.defaultValue t) + } + + let unresolved = selectedTests |> Array.filter (fun t -> Option.isNone t.ServerId) + + unresolved + |> TestRun.showError + testRun + "This test was not found by test discovery, so it cannot be run. Try refreshing the test explorer" + + let runnable = selectedTests |> Array.filter (fun t -> Option.isSome t.ServerId) + let testIds = runnable |> Array.choose (fun t -> t.ServerId) |> Array.distinct + + return runnable, testIds + } + let private runTests_WithLanguageServer mergeTestResultsToExplorer + (rediscover: unit -> JS.Promise) (rootTestCollection: TestItemCollection) (req: TestRunRequest) testRun = promise { try - let expectedToRun = - req.``include`` - |> Option.map Array.ofSeq - |> Option.defaultValue (rootTestCollection.TestItems()) - |> Array.collect TestItem.runnableChildren + let! expectedToRun, testIds = + match req.``include`` |> Option.map Array.ofSeq with + | Some selectedCases when not (Array.isEmpty selectedCases) -> + promise { + let! runnable, testIds = + resolveSelectedTestIds rediscover rootTestCollection testRun selectedCases - let expectedTestsById = expectedToRun |> Array.map (fun t -> t.id, t) |> Map + return runnable, Some testIds + } + | _ -> + Promise.lift (rootTestCollection.TestItems() |> Array.collect TestItem.runnableChildren, None) + + let tryFindExpected = + TestItem.expectedLookup expectedToRun (fun (t: TestItemDTO) -> + Some t.Id, TestItem.constructId t.ProjectFilePath t.FullName) let mergeResults (shouldTrim: TrimMissing) (resultDtos: TestResultDTO array) = let actuallyRan: TestResult array = @@ -1842,15 +1932,7 @@ module Interactions = let showStarted (testItems: TestItemDTO array) = try - let groups = testItems |> Array.groupBy (fun t -> t.ProjectFilePath) - - groups - |> Array.iter (fun (projPath, activeTests) -> - let testIdsToStart = - activeTests |> Array.map (fun t -> TestItem.constructId projPath t.FullName) - - let knownExplorerItems = testIdsToStart |> Array.choose expectedTestsById.TryFind - knownExplorerItems |> TestRun.showStarted testRun) + testItems |> Array.choose tryFindExpected |> TestRun.showStarted testRun with ex -> logger.Debug("Threw error while mapping active test items to the explorer", ex) @@ -1894,58 +1976,26 @@ module Interactions = let onAttachDebugger (processId: int) = VSCodeActions.launchDebugger (string processId) - /// Names the selected tests to Microsoft.Testing.Platform, which runs a test by - /// uid rather than by a filter expression. A grouping node holds no uid of its - /// own, so a selection is read as the runnable tests under it. - let testUids (selectedCases: TestItem seq) = - selectedCases - |> Array.ofSeq - |> TestItem.runnableFromArray - |> Array.choose (fun test -> test.PlatformUid) - |> Array.distinct - - let filterExpression, projectSubset, uids = - match req.``include`` with - | None -> None, None, None - | Some selectedCases when Seq.isEmpty selectedCases -> None, None, None - | Some selectedCases -> - let filter = - selectedCases - |> Array.ofSeq - |> Array.filter (fun t -> t.id |> TestItem.getFullName <> String.Empty) - |> buildFilterExpression - |> Some - - let projectSubset = - selectedCases - |> Seq.map (TestItem.getId >> TestItem.getProjectPath) - |> Seq.distinct - |> Array.ofSeq - |> Some - - filter, projectSubset, Some(testUids selectedCases) - - logger.Debug($"Test Filter Expression: {filterExpression}") - let shouldDebug = TestRunRequest.isDebugRequested req - let! runResult = - LanguageService.runTests - onTestRunProgress - onAttachDebugger - projectSubset - filterExpression - uids - shouldDebug + match testIds with + | Some ids when Array.isEmpty ids -> () + | _ -> + logger.Debug($"Test ids: {testIds}") - mergeResults TrimMissing.Trim runResult.Data + // A selection is named by id alone: FSAC rejects ids sent with a filter, and + // works out the projects to run from the ids. + let! runResult = + LanguageService.runTests onTestRunProgress onAttachDebugger None None testIds shouldDebug - if Array.isEmpty runResult.Data then - let message = - $"WARNING: No tests ran. The test explorer might be out of sync. Try running a higher test group or refreshing the test explorer" + mergeResults TrimMissing.Trim runResult.Data + + if Array.isEmpty runResult.Data then + let message = + $"WARNING: No tests ran. The test explorer might be out of sync. Try running a higher test group or refreshing the test explorer" - window.showWarningMessage (message) |> ignore - TestRun.Output.appendWarningLine testRun message + window.showWarningMessage (message) |> ignore + TestRun.Output.appendWarningLine testRun message with ex -> logger.Debug("Test run failed with exception", ex) TestRun.Output.appendErrorLine testRun $"The test run errored {Environment.NewLine}{string ex}" @@ -2019,7 +2069,17 @@ module Interactions = testRun.``end`` () else - do! runTests_WithLanguageServer mergeTestResultsToExplorer testController.items req testRun + let rediscover () = + discoverTests_WithLanguageServer testItemFactory testController.items tryGetLocation + + do! + runTests_WithLanguageServer + mergeTestResultsToExplorer + rediscover + testController.items + req + testRun + testRun.``end`` () do! discoverTests_WithLanguageServer testItemFactory testController.items tryGetLocation diff --git a/src/Core/DTO.fs b/src/Core/DTO.fs index a79a0f537..32d9c6e4d 100644 --- a/src/Core/DTO.fs +++ b/src/Core/DTO.fs @@ -372,7 +372,8 @@ module DTO = type TestItemDTO = { - /// Distinguishes this node from every other node reported by the server. + /// Distinguishes this node from every other node reported by the server. Opaque: the + /// server issues it, and a test is run by sending its `Id` back unchanged. Id: string /// The `Id` of the node one level up, or `None` at the root of a project. ParentId: string option @@ -384,9 +385,6 @@ module DTO = /// Example: executor://xunit/VsTestRunner2/netcoreapp /// Used for determining the test library, which effects how tests names are broken down ExecutorUri: string - /// Addresses a runnable test to Microsoft.Testing.Platform, which identifies tests by - /// opaque uid. A grouping node and a test run under VSTest carry `None`. - PlatformUid: string option ProjectFilePath: string TargetFramework: string CodeFilePath: string option diff --git a/src/Core/LanguageService.fs b/src/Core/LanguageService.fs index d480d5da9..333b0c780 100644 --- a/src/Core/LanguageService.fs +++ b/src/Core/LanguageService.fs @@ -97,10 +97,11 @@ module LanguageService = type TestRunRequest = { LimitToProjects: string array option + /// A VSTest filter expression. The server rejects it together with `TestIds`. TestCaseFilter: string option - /// Names the tests to run on Microsoft.Testing.Platform, by the uid each was - /// discovered under. `None` runs every test of the projects being run. - TestUids: string array option + /// Names the tests to run by the `Id` discovery reported for each. `None` runs + /// every test of the projects being run; an empty array runs none. + TestIds: string array option AttachDebugger: bool } @@ -627,7 +628,7 @@ Consider: (onAttachDebugger: ProcessId -> JS.Promise) (projectSubset: string array option) (testCaseFilter: string option) - (testUids: string array option) + (testIds: string array option) (attachDebugger: bool) = match client with @@ -652,7 +653,7 @@ Consider: let request: Types.TestRunRequest = { LimitToProjects = projectSubset TestCaseFilter = testCaseFilter - TestUids = testUids + TestIds = testIds AttachDebugger = attachDebugger } cl.sendRequest ("test/runTests", request) From 06a81374a96761ec992327ff20dd1cdbd803393f Mon Sep 17 00:00:00 2001 From: shayanhabibi Date: Fri, 25 Sep 2026 17:28:27 +0800 Subject: [PATCH 5/7] Keep tests FSAC tells apart apart in the explorer 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. --- src/Components/TestExplorer.fs | 53 +++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/src/Components/TestExplorer.fs b/src/Components/TestExplorer.fs index 9ff01c6a0..f5b1484c5 100644 --- a/src/Components/TestExplorer.fs +++ b/src/Components/TestExplorer.fs @@ -769,6 +769,10 @@ module TestItem = let getId (t: TestItem) = t.id + /// Tells apart the tests a run selects. The `Id` FSAC reported is unique where the name-based + /// id is not, such as for tests of one display name under different parents. + let getRunKey (t: TestItem) = t.ServerId |> Option.defaultValue t.id + let tryPick (f: TestItem -> Option<'u>) root = let rec recurse testItem = let searchResult = f testItem @@ -786,7 +790,13 @@ module TestItem = if testItem.children.size = 0. then [| testItem |] else - testItem.children.TestItems() |> Array.collect visit + let runnableBelow = testItem.children.TestItems() |> Array.collect visit + + // A test FSAC reported can hold tests of its own, and runs alongside them. + if Option.isSome testItem.ServerId then + Array.append [| testItem |] runnableBelow + else + runnableBelow visit root @@ -794,7 +804,7 @@ module TestItem = testCollection |> Array.collect runnableChildren // NOTE: there can be duplicates. i.e. if a child and parent are both selected in the explorer - |> Array.distinctBy getId + |> Array.distinctBy getRunKey /// Finds the item among `items` that something reported by FSAC refers to. The `Id` FSAC /// reported decides when there is one; the name-based id is only used for items FSAC did not @@ -1001,20 +1011,41 @@ module TestItem = recurse hierarchy - /// The tree the server reported, linked through `ParentId`. + /// The tree the server reported, linked through `ParentId`. An explorer item is named by + /// its full name, which FSAC does not keep unique among siblings: a grouping can share its + /// name with a test, and tests can share a display name. A grouping is shown as one node + /// with the test of its name, and each further test of a name gets a numbered name, so that + /// no sibling replaces another in the tree. let hierarchyOfDtos (flatTests: TestItemDTO array) : TestName.NameHierarchy array = let byParent = flatTests |> Array.groupBy (fun dto -> dto.ParentId) |> Map.ofArray let childrenOf parentId = byParent |> Map.tryFind parentId |> Option.defaultValue [||] - let rec build (dto: TestItemDTO) : TestName.NameHierarchy = - { TestName.NameHierarchy.Data = (if dto.IsLeaf then Some dto else None) - TestName.NameHierarchy.FullName = dto.FullName - TestName.NameHierarchy.Name = (TestName.splitSegments dto.FullName |> List.last).Text - TestName.NameHierarchy.Children = childrenOf (Some dto.Id) |> Array.map build } + let rec build (siblings: TestItemDTO array) : TestName.NameHierarchy array = + siblings + |> Array.groupBy (fun dto -> dto.FullName) + |> Array.collect (fun (fullName, named) -> + let node data explorerName (holders: TestItemDTO array) = + { TestName.NameHierarchy.Data = data + TestName.NameHierarchy.FullName = explorerName + TestName.NameHierarchy.Name = (TestName.splitSegments fullName |> List.last).Text + TestName.NameHierarchy.Children = + holders |> Array.collect (fun dto -> childrenOf (Some dto.Id)) |> build } + + let tests, groupings = named |> Array.partition (fun dto -> dto.IsLeaf) + + if Array.isEmpty tests then + [| node None fullName groupings |] + else + tests + |> Array.mapi (fun index test -> + if index = 0 then + node (Some test) fullName (Array.append [| test |] groupings) + else + node (Some test) $"{fullName} #{index + 1}" [| test |])) - childrenOf None |> Array.map build + childrenOf None |> build let mapDtosForProject ((projectPath, targetFramework), flatTests) = let namedHierarchies = hierarchyOfDtos flatTests @@ -1605,11 +1636,11 @@ module Interactions = let expected = matched |> Array.map (fun (t, r) -> t.Value, r) let added = added |> Array.map snd - let matchedIds = expected |> Array.map (fst >> TestItem.getId) |> Set.ofArray + let matchedIds = expected |> Array.map (fst >> TestItem.getRunKey) |> Set.ofArray let missing = expectedToRun - |> Array.filter (fun t -> not (matchedIds.Contains(TestItem.getId t))) + |> Array.filter (fun t -> not (matchedIds.Contains(TestItem.getRunKey t))) expected |> Array.iter (displayTestResultInExplorer testRun) From 17f52c36d2f6dc75b4ecaf0cf4b957d7fb4eb1a1 Mon Sep 17 00:00:00 2001 From: shayanhabibi Date: Fri, 25 Sep 2026 19:59:42 +0800 Subject: [PATCH 6/7] Keep the id FSAC issued a test when its code is edited 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. --- src/Components/TestExplorer.fs | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Components/TestExplorer.fs b/src/Components/TestExplorer.fs index f5b1484c5..af4a5704a 100644 --- a/src/Components/TestExplorer.fs +++ b/src/Components/TestExplorer.fs @@ -1273,24 +1273,31 @@ module TestDiscovery = (previousCodeTests: TestItem array) (newCodeTests: TestItem array) = - let comparef (t: TestItem) = (t.id, rangeComparable t.range) - - let removed, unchanged, added = - ArrayExt.venn comparef comparef previousCodeTests newCodeTests + let removed, kept, added = + ArrayExt.venn TestItem.getId TestItem.getId previousCodeTests newCodeTests removed |> Array.map TestItem.getId |> Array.iter targetCollection.delete - added |> Array.iter targetCollection.add + // An item already in the tree is updated in place rather than re-added. `add` replaces the + // item with the same id, dropping the `Id` FSAC reported for it and the children FSAC reported under it + let updateInPlace (previousCodeChildren: TestItem array) (targetItem: TestItem) (newCodeTest: TestItem) = + targetItem.range <- newCodeTest.range + recurse targetItem.children previousCodeChildren (newCodeTest.children.TestItems()) + + added + |> Array.iter (fun newCodeTest -> + match targetCollection.get newCodeTest.id with + | None -> targetCollection.add newCodeTest + | Some targetItem -> updateInPlace [||] targetItem newCodeTest) - unchanged + kept |> Array.iter (fun (previousCodeTest, newCodeTest) -> match targetCollection.get newCodeTest.id with - | None -> () - | Some targetItem -> - recurse - targetItem.children - (previousCodeTest.children.TestItems()) - (newCodeTest.children.TestItems())) + | None -> + // a test gone from the tree comes back once its code moves + if rangeComparable previousCodeTest.range <> rangeComparable newCodeTest.range then + targetCollection.add newCodeTest + | Some targetItem -> updateInPlace (previousCodeTest.children.TestItems()) targetItem newCodeTest) recurse targetCollection previousCodeTests newCodeTests From a0ebc95392ce554322084959b9012246a952230d Mon Sep 17 00:00:00 2001 From: shayanhabibi Date: Fri, 25 Sep 2026 19:59:34 +0800 Subject: [PATCH 7/7] Build Microsoft.Testing.Platform test projects on refresh 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. --- src/Components/TestExplorer.fs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Components/TestExplorer.fs b/src/Components/TestExplorer.fs index af4a5704a..00014be2b 100644 --- a/src/Components/TestExplorer.fs +++ b/src/Components/TestExplorer.fs @@ -1196,12 +1196,19 @@ module ProjectExt = Project.getInWorkspace () |> List.map getPath + /// FSAC sends no IsTestingPlatformApplication, so a Microsoft.Testing.Platform project is + /// told by the platform package its runner brings in. Package references here are the + /// resolved ones, so the package counts even where only xunit.v3 or MSTest.Sdk is referenced. let isTestProject (project: Project) = let testProjectIndicators = - set [ "Microsoft.TestPlatform.TestHost"; "Microsoft.NET.Test.Sdk" ] - - project.PackageReferences - |> Array.exists (fun pr -> Set.contains pr.Name testProjectIndicators) + set + [ "Microsoft.TestPlatform.TestHost" + "Microsoft.NET.Test.Sdk" + "Microsoft.Testing.Platform" ] + + project.Info.IsTestProject + || project.PackageReferences + |> Array.exists (fun pr -> Set.contains pr.Name testProjectIndicators) type CodeBasedTestId = TestId