11using System ;
22using System . Collections . Concurrent ;
33using System . Collections . Generic ;
4+ using System . IO ;
5+ using System . Linq ;
6+ using System . Text ;
47using System . Threading ;
8+ using Microsoft . VisualStudio . TestPlatform . ObjectModel ;
59using Xunit . Runner . Common ;
610using Xunit . Sdk ;
711using VsTestCase = Microsoft . VisualStudio . TestPlatform . ObjectModel . TestCase ;
@@ -16,6 +20,8 @@ namespace Xunit.Runner.VisualStudio;
1620
1721internal sealed class VsExecutionSink : TestMessageSink , IDisposable
1822{
23+ static readonly HashSet < char > InvalidFileNameChars = Path . GetInvalidFileNameChars ( ) . ToHashSet ( ) ;
24+
1925 readonly Func < bool > cancelledThunk ;
2026 readonly LoggerHelper logger ;
2127 readonly IMessageSink innerSink ;
@@ -24,10 +30,12 @@ internal sealed class VsExecutionSink : TestMessageSink, IDisposable
2430 readonly ConcurrentDictionary < string , DateTimeOffset > startTimeByTestID = [ ] ;
2531 readonly ConcurrentDictionary < string , List < ITestCaseStarting > > testCasesByAssemblyID = [ ] ;
2632 readonly ConcurrentDictionary < string , ITestCaseStarting > testCasesByCaseID = [ ] ;
33+ readonly ConcurrentDictionary < string , ( string actionDescription , ITestMessage test , VsTestResult testResult ) > testResultByCaseID = [ ] ;
2734 readonly ConcurrentDictionary < string , List < ITestCaseStarting > > testCasesByClassID = [ ] ;
2835 readonly ConcurrentDictionary < string , List < ITestCaseStarting > > testCasesByCollectionID = [ ] ;
2936 readonly ConcurrentDictionary < string , List < ITestCaseStarting > > testCasesByMethodID = [ ] ;
3037 readonly Dictionary < string , VsTestCase > testCasesMap ;
38+ static readonly Uri uri = new ( Constants . ExecutorUri ) ;
3139
3240 public VsExecutionSink (
3341 IMessageSink innerSink ,
@@ -298,16 +306,77 @@ void HandleTestFailed(MessageHandlerArgs<ITestFailed> args)
298306 result . ErrorMessage = ExceptionUtility . CombineMessages ( testFailed ) ;
299307 result . ErrorStackTrace = ExceptionUtility . CombineStackTraces ( testFailed ) ;
300308
301- TryAndReport ( "RecordResult (Fail)" , testFailed , ( ) => recorder . RecordResult ( result ) ) ;
309+ DeferReportUntilTestFinished ( "RecordResult (Fail)" , testFailed , result ) ;
302310 }
303311 else
304312 LogWarning ( testFailed , "(Fail) Could not find VS test case for {0} (ID = {1})" , TestDisplayName ( testFailed ) , testFailed . TestCaseUniqueID ) ;
305313
306314 HandleCancellation ( args ) ;
307315 }
308316
309- void HandleTestFinished ( MessageHandlerArgs < ITestFinished > args ) =>
317+ void HandleTestFinished ( MessageHandlerArgs < ITestFinished > args )
318+ {
319+ var testUniqueID = args . Message . TestUniqueID ;
320+
321+ if ( testResultByCaseID . TryRemove ( testUniqueID , out var testResultEntry ) )
322+ {
323+ var ( actionDescription , test , testResult ) = testResultEntry ;
324+ var attachments = args . Message . Attachments ;
325+
326+ if ( attachments . Count != 0 )
327+ try
328+ {
329+ var basePath = Path . Combine ( Path . GetTempPath ( ) , testUniqueID ) ;
330+ Directory . CreateDirectory ( basePath ) ;
331+
332+ var attachmentSet = new AttachmentSet ( uri , "xUnit.net" ) ;
333+
334+ foreach ( var kvp in attachments )
335+ {
336+ var localFilePath = Path . Combine ( basePath , SanitizeFileName ( kvp . Key ) ) ;
337+
338+ try
339+ {
340+ var attachmentType = kvp . Value . AttachmentType ;
341+
342+ if ( attachmentType == TestAttachmentType . String )
343+ {
344+ localFilePath += ".txt" ;
345+ File . WriteAllText ( localFilePath , kvp . Value . AsString ( ) ) ;
346+ }
347+ else if ( attachmentType == TestAttachmentType . ByteArray )
348+ {
349+ var ( byteArray , mediaType ) = kvp . Value . AsByteArray ( ) ;
350+ localFilePath += MediaTypeUtility . GetFileExtension ( mediaType ) ;
351+ File . WriteAllBytes ( localFilePath , byteArray ) ;
352+ }
353+ else
354+ {
355+ LogWarning ( test , "Unknown test attachment type '{0}' for attachment '{1}' [test case ID '{2}']" , attachmentType , kvp . Key , testUniqueID ) ;
356+ localFilePath = null ;
357+ }
358+
359+ if ( localFilePath is not null )
360+ attachmentSet . Attachments . Add ( UriDataAttachment . CreateFrom ( localFilePath , kvp . Key ) ) ;
361+ }
362+ catch ( Exception ex )
363+ {
364+ LogWarning ( test , "Exception while adding attachment '{0}' in '{1}' [test case ID '{2}']: {3}" , kvp . Key , localFilePath , testUniqueID , ex ) ;
365+ }
366+ }
367+
368+ testResult . Attachments . Add ( attachmentSet ) ;
369+ }
370+ catch ( Exception ex )
371+ {
372+ LogWarning ( test , "Exception while adding attachments [test case ID '{0}']: {1}" , testUniqueID , ex ) ;
373+ }
374+
375+ TryAndReport ( actionDescription , test , ( ) => recorder . RecordResult ( testResult ) ) ;
376+ }
377+
310378 MetadataCache ( args . Message ) ? . TryRemove ( args . Message ) ;
379+ }
311380
312381 void HandleTestMethodCleanupFailure ( MessageHandlerArgs < ITestMethodCleanupFailure > args )
313382 {
@@ -340,7 +409,7 @@ void HandleTestNotRun(MessageHandlerArgs<ITestNotRun> args)
340409
341410 var result = MakeVsTestResult ( VsTestOutcome . None , testNotRun , startTime ) ;
342411 if ( result is not null )
343- TryAndReport ( "RecordResult (None)" , testNotRun , ( ) => recorder . RecordResult ( result ) ) ;
412+ DeferReportUntilTestFinished ( "RecordResult (None)" , testNotRun , result ) ;
344413 else
345414 LogWarning ( testNotRun , "(NotRun) Could not find VS test case for {0} (ID = {1})" , TestDisplayName ( testNotRun ) , testNotRun . TestCaseUniqueID ) ;
346415
@@ -354,7 +423,7 @@ void HandleTestPassed(MessageHandlerArgs<ITestPassed> args)
354423
355424 var result = MakeVsTestResult ( VsTestOutcome . Passed , testPassed , startTime ) ;
356425 if ( result is not null )
357- TryAndReport ( "RecordResult (Pass)" , testPassed , ( ) => recorder . RecordResult ( result ) ) ;
426+ DeferReportUntilTestFinished ( "RecordResult (Pass)" , testPassed , result ) ;
358427 else
359428 LogWarning ( testPassed , "(Pass) Could not find VS test case for {0} (ID = {1})" , TestDisplayName ( testPassed ) , testPassed . TestCaseUniqueID ) ;
360429
@@ -368,7 +437,7 @@ void HandleTestSkipped(MessageHandlerArgs<ITestSkipped> args)
368437
369438 var result = MakeVsTestResult ( VsTestOutcome . Skipped , testSkipped , startTime ) ;
370439 if ( result is not null )
371- TryAndReport ( "RecordResult (Skip)" , testSkipped , ( ) => recorder . RecordResult ( result ) ) ;
440+ DeferReportUntilTestFinished ( "RecordResult (Skip)" , testSkipped , result ) ;
372441 else
373442 LogWarning ( testSkipped , "(Skip) Could not find VS test case for {0} (ID = {1})" , TestDisplayName ( testSkipped ) , testSkipped . TestCaseUniqueID ) ;
374443
@@ -494,6 +563,25 @@ string TestDisplayName(ITestMessage msg) =>
494563 string TestMethodName ( ITestMethodMessage msg ) =>
495564 TestClassName ( msg ) + "." + MetadataCache ( msg ) ? . TryGetMethodMetadata ( msg ) ? . MethodName ?? $ "<unknown test method ID { msg . TestMethodUniqueID } >";
496565
566+ void DeferReportUntilTestFinished (
567+ string actionDescription ,
568+ ITestMessage test ,
569+ VsTestResult testResult ) =>
570+ testResultByCaseID . TryAdd ( test . TestUniqueID , ( actionDescription , test , testResult ) ) ;
571+
572+ string SanitizeFileName ( string fileName )
573+ {
574+ var result = new StringBuilder ( fileName . Length ) ;
575+
576+ foreach ( var c in fileName )
577+ if ( InvalidFileNameChars . Contains ( c ) )
578+ result . Append ( '_' ) ;
579+ else
580+ result . Append ( c ) ;
581+
582+ return result . ToString ( ) ;
583+ }
584+
497585 void TryAndReport (
498586 string actionDescription ,
499587 ITestCaseMessage testCase ,
0 commit comments