diff --git a/src/xunit.runner.visualstudio/Utility/AppDomainManager.cs b/src/xunit.runner.visualstudio/Utility/AppDomainManager.cs new file mode 100644 index 0000000..96722ca --- /dev/null +++ b/src/xunit.runner.visualstudio/Utility/AppDomainManager.cs @@ -0,0 +1,73 @@ +#if NETFRAMEWORK + +using System; +using System.IO; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Security; +using System.Security.Permissions; +using Xunit.Internal; + +namespace Xunit.Runner.VisualStudio; + +class AppDomainManager +{ + readonly AppDomain appDomain; + + public AppDomainManager(string assemblyFileName) + { + Guard.ArgumentNotNullOrEmpty(assemblyFileName); + + assemblyFileName = Path.GetFullPath(assemblyFileName); + Guard.FileExists(assemblyFileName); + + var applicationBase = Path.GetDirectoryName(assemblyFileName); + var applicationName = Guid.NewGuid().ToString(); + var setup = new AppDomainSetup + { + ApplicationBase = applicationBase, + ApplicationName = applicationName, + ShadowCopyFiles = "true", + ShadowCopyDirectories = applicationBase, + CachePath = Path.Combine(Path.GetTempPath(), applicationName) + }; + + appDomain = AppDomain.CreateDomain(Path.GetFileNameWithoutExtension(assemblyFileName), AppDomain.CurrentDomain.Evidence, setup, new PermissionSet(PermissionState.Unrestricted)); + } + + public TObject? CreateObject( + AssemblyName assemblyName, + string typeName, + params object[] args) + where TObject : class + { + try + { + return appDomain.CreateInstanceAndUnwrap(assemblyName.FullName, typeName, false, BindingFlags.Default, null, args, null, null) as TObject; + } + catch (TargetInvocationException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException ?? ex).Throw(); + return default; // Will never reach here, but the compiler doesn't know that + } + } + + public virtual void Dispose() + { + if (appDomain is not null) + { + var cachePath = appDomain.SetupInformation.CachePath; + + try + { + AppDomain.Unload(appDomain); + + if (cachePath is not null) + Directory.Delete(cachePath, true); + } + catch { } + } + } +} + +#endif diff --git a/src/xunit.runner.visualstudio/Utility/AssemblyExtensions.cs b/src/xunit.runner.visualstudio/Utility/AssemblyExtensions.cs index 3da9779..b2ce647 100644 --- a/src/xunit.runner.visualstudio/Utility/AssemblyExtensions.cs +++ b/src/xunit.runner.visualstudio/Utility/AssemblyExtensions.cs @@ -1,6 +1,9 @@ +using System.Reflection; + +#if NETFRAMEWORK using System; using System.IO; -using System.Reflection; +#endif internal static class AssemblyExtensions { diff --git a/src/xunit.runner.visualstudio/Utility/DiaSessionWrapper.cs b/src/xunit.runner.visualstudio/Utility/DiaSessionWrapper.cs new file mode 100644 index 0000000..f83109e --- /dev/null +++ b/src/xunit.runner.visualstudio/Utility/DiaSessionWrapper.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Navigation; +using Xunit.Internal; +using Xunit.Runner.Common; + +namespace Xunit.Runner.VisualStudio; + +// This class wraps DiaSession, and uses DiaSessionWrapperHelper to discover when a test is an async test +// (since that requires special handling by DIA). The wrapper helper needs to exist in a separate AppDomain +// so that we can do discovery without locking the assembly under test (for .NET Framework). +class DiaSessionWrapper : IDisposable +{ +#if NETFRAMEWORK + readonly AppDomainManager? appDomainManager; +#endif + readonly DiaSessionWrapperHelper? helper; + readonly DiaSession? session; + readonly DiagnosticMessageSink diagnosticMessageSink; + + public DiaSessionWrapper( + string assemblyFileName, + DiagnosticMessageSink diagnosticMessageSink) + { + this.diagnosticMessageSink = Guard.ArgumentNotNull(diagnosticMessageSink); + + try + { + session = new DiaSession(assemblyFileName); + } + catch (Exception ex) + { + diagnosticMessageSink.OnMessage(new InternalDiagnosticMessage($"Exception creating DiaSession: {ex}")); + } + + try + { +#if NETFRAMEWORK + var adapterFileName = typeof(DiaSessionWrapperHelper).Assembly.GetLocalCodeBase(); + if (adapterFileName is not null) + { + appDomainManager = new AppDomainManager(assemblyFileName); + helper = appDomainManager.CreateObject(typeof(DiaSessionWrapperHelper).Assembly.GetName(), typeof(DiaSessionWrapperHelper).FullName!, adapterFileName); + } +#else + helper = new DiaSessionWrapperHelper(assemblyFileName); +#endif + } + catch (Exception ex) + { + diagnosticMessageSink.OnMessage(new DiagnosticMessage($"Exception creating DiaSessionWrapperHelper: {ex}")); + } + } + + public INavigationData? GetNavigationData( + string typeName, + string methodName) + { + if (session is null || helper is null) + return null; + + try + { + helper.Normalize(ref typeName, ref methodName); + return session.GetNavigationDataForMethod(typeName, methodName); + } + catch (Exception ex) + { + diagnosticMessageSink.OnMessage(new DiagnosticMessage($"Exception getting source mapping for {typeName}.{methodName}: {ex}")); + return null; + } + } + + public void Dispose() + { + session?.Dispose(); +#if NETFRAMEWORK + appDomainManager?.Dispose(); +#endif + } +} diff --git a/src/xunit.runner.visualstudio/Utility/DiaSessionWrapperHelper.cs b/src/xunit.runner.visualstudio/Utility/DiaSessionWrapperHelper.cs new file mode 100644 index 0000000..faf72e3 --- /dev/null +++ b/src/xunit.runner.visualstudio/Utility/DiaSessionWrapperHelper.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using Xunit.Internal; +using Xunit.Sdk; + +namespace Xunit.Runner.VisualStudio; + +class DiaSessionWrapperHelper : LongLivedMarshalByRefObject +{ + readonly Assembly? assembly; + readonly Dictionary typeNameMap; + + public DiaSessionWrapperHelper(string assemblyFileName) + { + try + { +#if NETFRAMEWORK + assembly = Assembly.ReflectionOnlyLoadFrom(assemblyFileName); + var assemblyDirectory = Path.GetDirectoryName(assemblyFileName); + + if (assemblyDirectory is not null) + AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += (sender, args) => + { + try + { + // Try to load it normally + var name = AppDomain.CurrentDomain.ApplyPolicy(args.Name); + return Assembly.ReflectionOnlyLoad(name); + } + catch + { + try + { + // If a normal implicit load fails, try to load it from the directory that + // the test assembly lives in + return Assembly.ReflectionOnlyLoadFrom( + Path.Combine( + assemblyDirectory, + new AssemblyName(args.Name).Name + ".dll" + ) + ); + } + catch + { + // If all else fails, say we couldn't find it + return null; + } + } + }; +#else + assembly = Assembly.Load(new AssemblyName { Name = Path.GetFileNameWithoutExtension(assemblyFileName) }); +#endif + } + catch { } + + if (assembly is not null) + { + Type?[]? types = null; + + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types; + } + catch { } // Ignore anything other than ReflectionTypeLoadException + + if (types is not null) + typeNameMap = + types + .WhereNotNull() + .Where(t => !string.IsNullOrEmpty(t.FullName)) + .ToDictionaryIgnoringDuplicateKeys(k => k.FullName!); + } + + typeNameMap ??= []; + } + + public void Normalize( + ref string typeName, + ref string methodName) + { + try + { + if (assembly is null) + return; + + if (typeNameMap.TryGetValue(typeName, out var type) && type is not null) + { + var method = type.GetMethod(methodName); + if (method is not null && method.DeclaringType is not null && method.DeclaringType.FullName is not null) + { + // DiaSession only ever wants you to ask for the declaring type + typeName = method.DeclaringType.FullName; + + // See if this is an async method by looking for [AsyncStateMachine] on the method, + // which means we need to pass the state machine's "MoveNext" method. + var stateMachineType = method.GetCustomAttribute()?.StateMachineType; + if (stateMachineType is not null && stateMachineType.FullName is not null) + { + typeName = stateMachineType.FullName; + methodName = "MoveNext"; + } + } + } + } + catch { } + } +} diff --git a/src/xunit.runner.visualstudio/Utility/VisualStudioSourceInformationProvider.cs b/src/xunit.runner.visualstudio/Utility/VisualStudioSourceInformationProvider.cs new file mode 100644 index 0000000..44461f7 --- /dev/null +++ b/src/xunit.runner.visualstudio/Utility/VisualStudioSourceInformationProvider.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Xunit.Runner.Common; +using Xunit.Sdk; + +namespace Xunit.Runner.VisualStudio; + +/// +/// An implementation of that will provide source information +/// when running inside of Visual Studio (via the DiaSession class). +/// +/// The assembly file name. +/// The message sink to send internal diagnostic messages to. +internal class VisualStudioSourceInformationProvider( + string assemblyFileName, + DiagnosticMessageSink diagnosticMessageSink) : + LongLivedMarshalByRefObject, ISourceInformationProvider +{ + static readonly SourceInformation EmptySourceInformation = new(); + + readonly DiaSessionWrapper session = new DiaSessionWrapper(assemblyFileName, diagnosticMessageSink); + + /// + public SourceInformation GetSourceInformation( + string? testClassName, + string? testMethodName) + { + if (testClassName is null || testMethodName is null) + return EmptySourceInformation; + + var navData = session.GetNavigationData(testClassName, testMethodName); + if (navData is null || navData.FileName is null) + return EmptySourceInformation; + + return new SourceInformation(navData.FileName, navData.MinLineNumber); + } + + /// + public ValueTask DisposeAsync() + { + session.Dispose(); + + return default; + } +} diff --git a/src/xunit.runner.visualstudio/VsTestRunner.cs b/src/xunit.runner.visualstudio/VsTestRunner.cs index 4d85b3a..b5d509d 100644 --- a/src/xunit.runner.visualstudio/VsTestRunner.cs +++ b/src/xunit.runner.visualstudio/VsTestRunner.cs @@ -217,7 +217,8 @@ async Task DiscoverTests( var assemblyDisplayName = Path.GetFileNameWithoutExtension(assembly.AssemblyFileName); var diagnosticMessageSink = new DiagnosticMessageSink(logger, assemblyDisplayName, assembly.Configuration.DiagnosticMessagesOrDefault, assembly.Configuration.InternalDiagnosticMessagesOrDefault); - await using var controller = XunitFrontController.Create(assembly, null, diagnosticMessageSink); + await using var sourceInformationProvider = new VisualStudioSourceInformationProvider(assemblyFileName, diagnosticMessageSink); + await using var controller = XunitFrontController.Create(assembly, sourceInformationProvider, diagnosticMessageSink); if (controller is null) return; @@ -508,6 +509,7 @@ async Task RunTestsInAssembly( if (runContext.IsBeingDebugged && frameworkHandle2 is not null) testProcessLauncher = new DebuggerProcessLauncher(frameworkHandle2); + await using var sourceInformationProvider = new VisualStudioSourceInformationProvider(assemblyFileName, diagnosticSink); await using var controller = XunitFrontController.Create(runInfo.Assembly, null, diagnosticSink, testProcessLauncher); if (controller is null) return;