-
-
Notifications
You must be signed in to change notification settings - Fork 282
/
Copy pathTestExplorer.fs
1869 lines (1424 loc) · 69.9 KB
/
TestExplorer.fs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
module Ionide.VSCode.FSharp.TestExplorer
open System
open System.Text
open Fable.Core
open Fable.Import.VSCode
open Fable.Import.VSCode.Vscode
open Ionide.VSCode.FSharp.Import
open Ionide.VSCode.FSharp.Import.XmlDoc
open Fable.Core.JsInterop
open DTO
open Ionide.VSCode.Helpers
module node = Node.Api
let private lastOutput = Collections.Generic.Dictionary<string, string>()
let private outputChannel = window.createOutputChannel "F# - Test Adapter"
let private maxParallelTestProjects = 3
let private logger =
ConsoleAndOutputChannelLogger(Some "TestExplorer", Level.DEBUG, Some outputChannel, Some Level.DEBUG)
module ArrayExt =
let venn
(leftIdf: 'Left -> 'Id)
(rightIdf: 'Right -> 'Id)
(left: 'Left array)
(right: 'Right array)
: ('Left array * ('Left * 'Right) array * 'Right array) =
let leftIdMap =
left
|> Array.map (fun l -> (leftIdf l, l))
|> dict
|> Collections.Generic.Dictionary
let rightIdMap =
right
|> Array.map (fun r -> (rightIdf r, r))
|> dict
|> Collections.Generic.Dictionary
let leftIds = set leftIdMap.Keys
let rightIds = set rightIdMap.Keys
let intersection = Set.intersect leftIds rightIds
let idToTuple id = (leftIdMap.[id], rightIdMap.[id])
let intersectionPairs = intersection |> Array.ofSeq |> Array.map idToTuple
let leftExclusiveIds = Set.difference leftIds intersection
let rightExclusiveIds = Set.difference rightIds intersection
let dictGet (dict: Collections.Generic.Dictionary<'Id, 'T>) id = dict.[id]
let leftExclusive = leftExclusiveIds |> Array.ofSeq |> Array.map (dictGet leftIdMap)
let rightExclusive =
rightExclusiveIds |> Array.ofSeq |> Array.map (dictGet rightIdMap)
(leftExclusive, intersectionPairs, rightExclusive)
let mapKeepInput f col =
col |> Array.map (fun input -> (input, f input))
module ListExt =
let mapKeepInputAsync (f: 'a -> JS.Promise<'b>) col =
col
|> List.map (fun input ->
promise {
let! res = f input
return (input, res)
})
let mapPartitioned f (left, right) =
(left |> List.map f), (right |> List.map f)
module Dict =
let tryGet (d: Collections.Generic.IDictionary<'key, 'value>) (key) : 'value option =
if d.ContainsKey(key) then Some d[key] else None
module CancellationToken =
let mergeTokens (tokens: CancellationToken list) =
let tokenSource = vscode.CancellationTokenSource.Create()
if tokens |> List.exists (fun t -> t.isCancellationRequested) then
tokenSource.cancel ()
else
for t in tokens do
t.onCancellationRequested.Invoke(fun _ ->
tokenSource.cancel ()
None)
|> ignore
tokenSource.token
type TestId = string
type ProjectPath = string
type TargetFramework = string
module ProjectPath =
let inline ofString str = str
let fromProject (project: Project) = project.Project
type FullTestName = string
module FullTestName =
let inline ofString str = str
module TestName =
let pathSeparator = '.'
type Segment =
{ Text: string
SeparatorBefore: string }
module Segment =
let empty = { Text = ""; SeparatorBefore = "" }
let private segmentRegex = RegularExpressions.Regex(@"([+\.]?)([^+\.]+)")
let splitSegments (fullTestName: FullTestName) =
let matches =
[ for x in segmentRegex.Matches(fullTestName) do
x ]
matches
|> List.map (fun m ->
{ Text = m.Groups[2].Value
SeparatorBefore = m.Groups[1].Value })
let appendSegment (parentPath: FullTestName) (segment: Segment) : FullTestName =
$"{parentPath}{segment.SeparatorBefore}{segment.Text}"
let fromPathAndTestName (classPath: string) (testName: string) : FullTestName =
if classPath = "" then
testName
else
$"{classPath}.{testName}"
type private DataWithRelativePath<'t> =
{ data: 't; relativePath: Segment list }
type NameHierarchy<'t> =
{ Data: 't option
FullName: FullTestName
Name: string
Children: NameHierarchy<'t> array }
module NameHierarchy =
let tryPick (f: NameHierarchy<'t> -> Option<'u>) root =
let rec recurse hierarchy =
let searchResult = f hierarchy
if Option.isSome searchResult then
searchResult
else
hierarchy.Children |> Array.tryPick recurse
recurse root
let inferHierarchy (namedData: {| FullName: string; Data: 't |} array) : NameHierarchy<'t> array =
let withRelativePath (named: {| FullName: string; Data: 't |}) =
{ data = named.Data
relativePath = splitSegments named.FullName }
let popTopPath data =
{ data with
relativePath = data.relativePath.Tail }
let rec recurse (parentPath: string) defsWithRelativePath : NameHierarchy<'t> array =
let terminalNodes, intermediateNodes =
defsWithRelativePath |> Array.partition (fun d -> d.relativePath.Length = 1)
let mappedTerminals =
terminalNodes
|> Array.map (fun terminal ->
let segment = terminal.relativePath.Head
{ Name = segment.Text
FullName = appendSegment parentPath segment
Data = Some terminal.data
Children = [||] })
let mappedIntermediate =
intermediateNodes
|> Array.groupBy (fun d -> d.relativePath.Head)
|> Array.map (fun (groupSegment, children) ->
let fullName = appendSegment parentPath groupSegment
{ Name = groupSegment.Text
Data = None
FullName = appendSegment parentPath groupSegment
Children = recurse fullName (children |> Array.map popTopPath) })
Array.concat [ mappedTerminals; mappedIntermediate ]
namedData |> Array.map withRelativePath |> recurse ""
type TestItemCollection with
member x.TestItems() : TestItem array =
let arr = ResizeArray<TestItem>()
x.forEach (fun t _ -> !! arr.Add(t))
arr.ToArray()
type TestController with
member x.TestItems() : TestItem array = x.items.TestItems()
type TestItem with
member this.TestFramework: string = this?testFramework
[<RequireQualifiedAccess; StringEnum(CaseRules.None)>]
type TestResultOutcome =
| NotExecuted
| Failed
| Passed
type TestFrameworkId = string
module TestFrameworkId =
[<Literal>]
let NUnit = "NUnit"
[<Literal>]
let MsTest = "MSTest"
[<Literal>]
let XUnit = "XUnit"
[<Literal>]
let Expecto = "Expecto"
type TestResult =
{ FullTestName: string
Outcome: TestResultOutcome
ErrorMessage: string option
ErrorStackTrace: string option
Expected: string option
Actual: string option
Timing: float
TestFramework: TestFrameworkId option }
module Path =
let tryPath (path: string) =
if node.fs.existsSync (U2.Case1 path) then
Some path
else
None
let deleteIfExists (path: string) =
if node.fs.existsSync (U2.Case1 path) then
node.fs.unlinkSync (!^path)
let getNameOnly (path: string) =
node.path.basename (path, node.path.extname (path))
let split (path: string) : string array =
path.Split([| node.path.sep |], StringSplitOptions.RemoveEmptyEntries)
let private join segments = node.path.join (segments)
let removeSpecialRelativeSegments (path: string) : string =
let specialSegments = set [ ".."; "." ]
path |> split |> Array.skipWhile specialSegments.Contains |> join
module TrxParser =
let adapterTypeNameToTestFramework adapterTypeName =
if String.startWith "executor://nunit" adapterTypeName then
Some TestFrameworkId.NUnit
else if String.startWith "executor://mstest" adapterTypeName then
Some TestFrameworkId.MsTest
else if String.startWith "executor://xunit" adapterTypeName then
Some TestFrameworkId.XUnit
else if String.startWith "executor://yolodev" adapterTypeName then
Some TestFrameworkId.Expecto
else
None
type Execution = { Id: string }
type TestMethod =
{ AdapterTypeName: string
ClassName: string
Name: string }
type UnitTest =
{ Name: string
Execution: Execution
TestMethod: TestMethod }
member self.FullName =
// IMPORTANT: XUnit and MSTest don't include the parameterized test case data in the TestMethod.Name
// but NUnit and MSTest don't use fully qualified names in UnitTest.Name.
// Therefore, we have to conditionally build this full name based on the framework
match self.TestMethod.AdapterTypeName |> adapterTypeNameToTestFramework with
| Some TestFrameworkId.NUnit -> TestName.fromPathAndTestName self.TestMethod.ClassName self.TestMethod.Name
| Some TestFrameworkId.MsTest -> TestName.fromPathAndTestName self.TestMethod.ClassName self.Name
| _ -> self.Name
type ErrorInfo =
{ Message: string option
StackTrace: string option }
type Output = { ErrorInfo: ErrorInfo }
type UnitTestResult =
{ ExecutionId: string
Outcome: string
Duration: TimeSpan
Output: Output }
type TestWithResult =
{ UnitTest: UnitTest
UnitTestResult: UnitTestResult }
let makeTrxPath (workspaceRoot: string) (storageFolderPath: string) (projectPath: ProjectFilePath) : string =
let relativeProjectPath = node.path.relative (workspaceRoot, projectPath)
let projectName = Path.getNameOnly projectPath
let relativeResultsPath =
relativeProjectPath |> Path.removeSpecialRelativeSegments |> node.path.dirname
let trxPath =
node.path.resolve (storageFolderPath, "TestResults", relativeResultsPath, $"{projectName}.trx")
trxPath
let trxSelector (trxPath: string) : XPath.XPathSelector =
let trxContent = node.fs.readFileSync (trxPath, "utf8")
let xmlDoc = mkDoc trxContent
XPath.XPathSelector(xmlDoc, "http://microsoft.com/schemas/VisualStudio/TeamTest/2010")
let extractTestDefinitionsFromSelector (xpathSelector: XPath.XPathSelector) : UnitTest array =
let extractTestDef (node: XmlNode) : UnitTest =
let executionId = xpathSelector.SelectStringRelative(node, "t:Execution/@id")
// IMPORTANT: t:UnitTest/@name is not the same as t:TestMethod/@className + t:TestMethod/@name
// for theory tests in xUnit and MSTest https://github.com/ionide/ionide-vscode-fsharp/issues/1935
let fullTestName = xpathSelector.SelectStringRelative(node, "@name")
let className = xpathSelector.SelectStringRelative(node, "t:TestMethod/@className")
let testMethodName = xpathSelector.SelectStringRelative(node, "t:TestMethod/@name")
let testAdapter =
xpathSelector.SelectStringRelative(node, "t:TestMethod/@adapterTypeName")
{ Name = fullTestName
Execution = { Id = executionId }
TestMethod =
{ Name = testMethodName
ClassName = className
AdapterTypeName = testAdapter } }
xpathSelector.SelectNodes "/t:TestRun/t:TestDefinitions/t:UnitTest"
|> Array.map extractTestDef
let extractTestDefinitions (trxPath: string) =
let selector = trxSelector trxPath
extractTestDefinitionsFromSelector selector
let extractResultsSection (xpathSelector: XPath.XPathSelector) : UnitTestResult array =
let extractRow (node: XmlNode) : UnitTestResult =
let executionId = xpathSelector.SelectStringRelative(node, "@executionId")
let outcome = xpathSelector.SelectStringRelative(node, "@outcome")
let errorInfoMessage =
xpathSelector.TrySelectStringRelative(node, "t:Output/t:ErrorInfo/t:Message")
let errorStackTrace =
xpathSelector.TrySelectStringRelative(node, "t:Output/t:ErrorInfo/t:StackTrace")
let durationSpan =
let durationString = xpathSelector.SelectStringRelative(node, "@duration")
let success, ts = TimeSpan.TryParse(durationString)
if success then ts else TimeSpan.Zero
{ ExecutionId = executionId
Outcome = outcome
Duration = durationSpan
Output =
{ ErrorInfo =
{ StackTrace = errorStackTrace
Message = errorInfoMessage } } }
xpathSelector.SelectNodes "/t:TestRun/t:Results/t:UnitTestResult"
|> Array.map extractRow
let extractTrxResults (trxPath: string) =
let xpathSelector = trxSelector trxPath
let trxDefs = extractTestDefinitionsFromSelector xpathSelector
let trxResults = extractResultsSection xpathSelector
let trxDefId (testDef: UnitTest) = testDef.Execution.Id
let trxResId (res: UnitTestResult) = res.ExecutionId
let _, matched, _ = ArrayExt.venn trxDefId trxResId trxDefs trxResults
let matchedToResult (testDef: UnitTest, testResult: UnitTestResult) : TestWithResult =
{ UnitTest = testDef
UnitTestResult = testResult }
let normalizedResults = matched |> Array.map matchedToResult
normalizedResults
let inferHierarchy (testDefs: UnitTest array) : TestName.NameHierarchy<UnitTest> array =
testDefs
|> Array.map (fun td -> {| FullName = td.FullName; Data = td |})
|> TestName.inferHierarchy
module DotnetCli =
type StandardOutput = string
type StandardError = string
module Process =
open Ionide.VSCode.Helpers.CrossSpawn
open Ionide.VSCode.Helpers.Process
open Node.ChildProcess
let private cancelErrorMessage = "SIGINT"
/// <summary>
/// Fire off a command and gather the error, if any, and the stdout and stderr streams.
/// The command is fired from the workspace's root path.
/// </summary>
/// <param name="command">the 'base' command to execute</param>
/// <param name="args">an array of additional CLI args</param>
/// <returns></returns>
let execWithCancel
command
args
(env: obj option)
(outputCallback: Node.Buffer.Buffer -> unit)
(cancellationToken: CancellationToken)
: JS.Promise<ExecError option * string * string> =
if not cancellationToken.isCancellationRequested then
let options = createEmpty<ExecOptions>
options.cwd <- workspace.rootPath
env |> Option.iter (fun env -> options?env <- env)
Promise.create (fun resolve reject ->
let stdout = ResizeArray()
let stderr = ResizeArray()
let mutable error = None
let childProcess =
crossSpawn.spawn (command, args, options = options)
|> onOutput (fun e ->
outputCallback e
stdout.Add(string e))
|> onError (fun e -> error <- Some e)
|> onErrorOutput (fun e -> stderr.Add(string e))
|> onClose (fun code signal ->
resolve (unbox error, String.concat "\n" stdout, String.concat "\n" stderr))
cancellationToken.onCancellationRequested.Invoke(fun _ ->
childProcess.kill (cancelErrorMessage)
None)
|> ignore
)
else
promise { return (None, "", "") }
let restore
(projectPath: string)
: JS.Promise<Node.ChildProcess.ExecError option * StandardOutput * StandardError> =
Process.exec "dotnet" (ResizeArray([| "restore"; projectPath |]))
let private debugProcessIdRegex = RegularExpressions.Regex(@"Process Id: (.*),")
let private tryGetDebugProcessId consoleOutput =
let m = debugProcessIdRegex.Match(consoleOutput)
if m.Success then
let processId = m.Groups.[1].Value
Some processId
else
None
let private launchDebugger processId =
let launchRequest: DebugConfiguration =
{| name = ".NET Core Attach"
``type`` = "coreclr"
request = "attach"
processId = processId |}
|> box
|> unbox
let folder = workspace.workspaceFolders.Value.[0]
promise {
let! _ =
Vscode.debug.startDebugging (Some folder, U2.Case2 launchRequest)
|> Promise.ofThenable
// NOTE: Have to wait or it'll continue before the debugger reaches the stop on entry point.
// That'll leave the debugger in a confusing state where it shows it's attached but
// no breakpoints are hit and the breakpoints show as disabled
do! Promise.sleep 2000
Vscode.commands.executeCommand ("workbench.action.debug.continue") |> ignore
}
|> ignore
type DebugTests =
| Debug
| NoDebug
module DebugTests =
let ofBool bool = if bool then Debug else NoDebug
let private dotnetTest
(cancellationToken: CancellationToken)
(projectPath: string)
(targetFramework: string)
(trxOutputPath: string option)
(shouldDebug: DebugTests)
(additionalArgs: string array)
: JS.Promise<Node.ChildProcess.ExecError option * StandardOutput * StandardError> =
let args =
[| "test"
$"\"{projectPath}\""
$"--framework:\"{targetFramework}\""
if Option.isSome trxOutputPath then
$"--logger:\"trx;LogFileName={trxOutputPath.Value}\""
"--noLogo"
yield! additionalArgs |]
let argString = String.Join(" ", args)
logger.Debug($"Running `dotnet {argString}`")
match shouldDebug with
| Debug ->
let mutable isDebuggerStarted = false
let tryLaunchDebugger (consoleOutput: Node.Buffer.Buffer) =
if not isDebuggerStarted then
// NOTE: the processId we need to attach to is not the one we started for `dotnet test`.
// Dotnet test will return the correct process id if (and only if) we are in debug mode
match tryGetDebugProcessId (string consoleOutput) with
| None -> ()
| Some processId ->
launchDebugger processId
isDebuggerStarted <- true
let env =
let parentEnv = Node.Api.``process``.env
let childEnv = parentEnv
childEnv?VSTEST_HOST_DEBUG <- 1
childEnv |> box |> Some
Process.execWithCancel "dotnet" (ResizeArray(args)) env tryLaunchDebugger cancellationToken
| NoDebug -> Process.execWithCancel "dotnet" (ResizeArray(args)) None ignore cancellationToken
type TrxPath = string
type ConsoleOutput = string
let test
(projectPath: string)
(targetFramework: string)
(trxOutputPath: string option)
(filterExpression: string option)
(shouldDebug: DebugTests)
(cancellationToken: CancellationToken)
: JS.Promise<ConsoleOutput> =
promise {
// https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-test#filter-option-details
let filter =
match filterExpression with
| None -> Array.empty
| Some filterExpression -> [| "--filter"; $"\"{filterExpression}\"" |]
if filter.Length > 0 then
logger.Debug("Filter", filter)
let! errored, stdOutput, stdError =
dotnetTest
cancellationToken
projectPath
targetFramework
trxOutputPath
shouldDebug
[| "--no-build"; yield! filter |]
match errored with
| Some error -> logger.Error("Test run failed - %s - %s - %s", error, stdOutput, stdError)
| None -> logger.Debug("Test run exitCode - %s - %s", stdOutput, stdError)
return (stdOutput + stdError)
}
let listTests projectPath targetFramework (shouldBuild: bool) (cancellationToken: CancellationToken) =
let splitLines (str: string) =
str.Split([| "\r\n"; "\n\r"; "\n" |], StringSplitOptions.RemoveEmptyEntries)
promise {
let additionalArgs = if not shouldBuild then [| "--no-build" |] else Array.empty
let! _, stdOutput, _ =
dotnetTest
cancellationToken
projectPath
targetFramework
None
NoDebug
[| "--list-tests"; yield! additionalArgs |]
let testNames =
stdOutput
|> splitLines
|> Array.skipWhile (((<>) << String.trim) "The following Tests are available:")
|> Array.safeSkip 1
|> Array.choose (fun line ->
let line = line.TrimStart()
if (not << String.IsNullOrEmpty) line then
Some line
else
None)
return testNames
}
type LocationRecord =
{ Uri: Uri; Range: Vscode.Range option }
module LocationRecord =
let tryGetUri (l: LocationRecord option) = l |> Option.map (fun l -> l.Uri)
let tryGetRange (l: LocationRecord option) = l |> Option.bind (fun l -> l.Range)
let testToLocation (testItem: TestItem) =
match testItem.uri with
| None -> None
| Some uri -> Some { Uri = uri; Range = testItem.range }
type CodeLocationCache() =
let locationCache = Collections.Generic.Dictionary<TestId, LocationRecord>()
member _.Save(testId: TestId, location: LocationRecord) = locationCache[testId] <- location
member _.GetById(testId: TestId) = locationCache.TryGet testId
member _.GetKnownTestIds() : TestId seq = locationCache.Keys
member _.DeleteByFile(uri: Uri) =
for kvp in locationCache do
if kvp.Value.Uri.fsPath = uri.fsPath then
locationCache.Remove(kvp.Key) |> ignore
module TestItem =
let private idSeparator = " -- "
let constructId (projectPath: ProjectPath) (fullName: FullTestName) : TestId =
String.Join(idSeparator, [| projectPath; fullName |])
let constructProjectRootId (projectPath: ProjectPath) : TestId = constructId projectPath ""
let private componentizeId (testId: TestId) : (ProjectPath * FullTestName) =
let split =
testId.Split(separator = [| idSeparator |], options = StringSplitOptions.None)
(split.[0], split.[1])
let getFullName (testId: TestId) : FullTestName =
let _, fullName = componentizeId testId
fullName
let getProjectPath (testId: TestId) : ProjectPath =
let projectPath, _ = componentizeId testId
projectPath
let getId (t: TestItem) = t.id
let runnableChildren (root: TestItem) : TestItem array =
// The goal is to collect here the actual runnable tests, they might be nested under a tree structure.
let rec visit (testItem: TestItem) : TestItem array =
if testItem.children.size = 0. then
[| testItem |]
else
testItem.children.TestItems() |> Array.collect visit
visit root
let runnableFromArray (testCollection: TestItem array) : TestItem array =
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
let tryGetLocation (testItem: TestItem) =
match testItem.uri, testItem.range with
| Some uri, Some range -> Some(vscode.Location.Create(uri, !^range))
| _ -> None
let preWalk f (root: TestItem) =
let rec recurse (t: TestItem) =
let mapped = f t
let mappedChildren = t.children.TestItems() |> Array.collect recurse
Array.concat [ [| mapped |]; mappedChildren ]
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 }
type TestItemFactory = TestItemBuilder -> TestItem
let itemFactoryForController (testController: TestController) =
let factory builder =
let testItem =
match builder.uri with
| Some uri -> testController.createTestItem (builder.id, builder.label, uri)
| None -> testController.createTestItem (builder.id, builder.label)
builder.children |> Array.iter testItem.children.add
testItem.range <- builder.range
match builder.testFramework with
| Some frameworkId -> testItem?testFramework <- frameworkId
| None -> ()
testItem
factory
let fromNamedHierarchy
(itemFactory: TestItemFactory)
(tryGetLocation: TestId -> LocationRecord option)
projectPath
(hierarchy: TestName.NameHierarchy<'t>)
: TestItem =
let rec recurse (namedNode: TestName.NameHierarchy<'t>) =
let id = constructId projectPath namedNode.FullName
let location = tryGetLocation id
itemFactory
{ id = id
label = namedNode.Name
uri = location |> LocationRecord.tryGetUri
range = location |> LocationRecord.tryGetRange
children = namedNode.Children |> Array.map recurse
testFramework = None }
recurse hierarchy
let fromTestAdapter
(itemFactory: TestItemFactory)
(uri: Uri)
(projectPath: ProjectPath)
(t: TestAdapterEntry)
: TestItem =
let getNameSeparator parentModuleType moduleType =
match parentModuleType, moduleType with
| None, _ -> ""
| Some "NoneModule", _
| Some _, "NoneModule" -> "."
| _ -> "+"
let rec recurse (parentFullName: FullTestName) (parentModuleType: string option) (t: TestAdapterEntry) =
let fullName =
parentFullName + (getNameSeparator parentModuleType t.moduleType) + t.name
let range =
Some(
vscode.Range.Create(
vscode.Position.Create(t.range.start.line, t.range.start.character),
vscode.Position.Create(t.range.``end``.line, t.range.``end``.character)
)
)
let ti =
itemFactory
{ id = constructId projectPath fullName
label = t.name
uri = Some uri
range = range
children = t.childs |> Array.map (fun n -> recurse fullName (Some t.moduleType) n)
testFramework = t?``type`` }
ti
recurse "" None t
let fromProject
(testItemFactory: TestItemFactory)
(projectPath: ProjectPath)
(targetFramework: TargetFramework)
(children: TestItem array)
: TestItem =
testItemFactory
{ id = constructProjectRootId projectPath
label = $"{Path.getNameOnly projectPath} ({targetFramework})"
uri = None
range = None
children = children
testFramework = None }
let isProjectItem (testId: TestId) =
constructProjectRootId (getProjectPath testId) = testId
let tryFromTestForFile (testItemFactory: TestItemFactory) (testsForFile: TestForFile) =
let fileUri = vscode.Uri.parse (testsForFile.file, true)
Project.tryFindLoadedProjectByFile fileUri.fsPath
|> Option.map (fun project ->
let projectPath = ProjectPath.ofString project.Project
let fileTests =
testsForFile.tests
|> Array.map (fromTestAdapter testItemFactory fileUri projectPath)
[| fromProject testItemFactory projectPath project.Info.TargetFramework fileTests |])
let tryGetById (testId: TestId) (rootCollection: TestItem array) : TestItem option =
let projectPath, fullTestName = componentizeId testId
let rec recurse
(collection: TestItemCollection)
(parentPath: FullTestName)
(remainingPath: TestName.Segment list)
=
let currentLabel, remainingPath =
match remainingPath with
| currentLabel :: remainingPath -> (currentLabel, remainingPath)
| [] -> TestName.Segment.empty, []
let fullName = TestName.appendSegment parentPath currentLabel
let id = constructId projectPath fullName
let existingItem = collection.get (id)
match existingItem with
| None -> None
| Some existingItem ->
if remainingPath <> [] then
recurse existingItem.children fullName remainingPath
else
Some existingItem
let pathSegments = TestName.splitSegments fullTestName
rootCollection
|> Array.tryFind (fun ti -> ti.id = constructProjectRootId projectPath)
|> Option.bind (fun projectRoot -> recurse projectRoot.children "" pathSegments)
let getOrMakeHierarchyPath
(rootCollection: TestItemCollection)
(itemFactory: TestItemFactory)
(tryGetLocation: TestId -> LocationRecord option)
(projectPath: ProjectPath)
(targetFramework: TargetFramework)
(fullTestName: FullTestName)
=
let rec recurse
(collection: TestItemCollection)
(parentPath: FullTestName)
(remainingPath: TestName.Segment list)
=
let currentLabel, remainingPath =
match remainingPath with
| currentLabel :: remainingPath -> (currentLabel, remainingPath)
| [] -> TestName.Segment.empty, []
let fullName = TestName.appendSegment parentPath currentLabel
let id = constructId projectPath fullName
let maybeLocation = tryGetLocation id
let existingItem = collection.get (id)
let testItem =
match existingItem with
| Some existing -> existing
| None ->
itemFactory
{ id = id
label = currentLabel.Text
uri = maybeLocation |> LocationRecord.tryGetUri
range = maybeLocation |> LocationRecord.tryGetRange
children = [||]
testFramework = None }
collection.add (testItem)
if remainingPath <> [] then
recurse testItem.children fullName remainingPath
else
testItem
let getOrMakeProjectRoot projectPath targetFramework =
match rootCollection.get (constructProjectRootId projectPath) with
| None -> fromProject itemFactory projectPath targetFramework [||]
| Some projectTestItem -> projectTestItem
let projectRoot = getOrMakeProjectRoot projectPath targetFramework
let pathSegments = TestName.splitSegments fullTestName
recurse projectRoot.children "" pathSegments
module CodeLocationCache =
let cacheTestLocations (locationCache: CodeLocationCache) (filePath: string) (testItems: TestItem array) =
let fileUri = vscode.Uri.parse (filePath, true)
locationCache.DeleteByFile(fileUri)
let saveTestItem (testItem: TestItem) =
LocationRecord.testToLocation testItem
|> Option.iter (fun l -> locationCache.Save(testItem.id, l))
testItems |> Array.map (TestItem.preWalk saveTestItem) |> ignore
module ProjectExt =
let getAllWorkspaceProjects () =
let getPath (status: Project.ProjectLoadingState) =
match status with
| Project.ProjectLoadingState.Loaded p -> p.Project
| Project.ProjectLoadingState.LanguageNotSupported path -> path
| Project.ProjectLoadingState.Loading path -> path
| Project.ProjectLoadingState.Failed(path, _) -> path
| Project.ProjectLoadingState.NotRestored(path, _) -> path
Project.getInWorkspace () |> List.map getPath
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)
type CodeBasedTestId = TestId
type ResultBasedTestId = TestId
module TestDiscovery =
let tryMatchCodeLocations
(testItemFactory: TestItem.TestItemFactory)
(tryMatchDisplacedTest: TestId -> TestItem option)
(rootTestCollection: TestItemCollection)
(testsFromCode: TestItem array)
=
let cloneWithUri (target: TestItem, withUri: TestItem) =
let replacementItem =
testItemFactory
{ id = target.id
label = target.label
uri = withUri.uri
range = withUri.range
children = target.children.TestItems()
testFramework = withUri?testFramework }
(replacementItem, withUri)
let rec recurse (target: TestItemCollection) (withUri: TestItem array) : unit =
let treeOnly, matched, _codeOnly =