Skip to content

Commit

Permalink
Restore VisualStudioSourceInformationProvider for #433
Browse files Browse the repository at this point in the history
  • Loading branch information
bradwilson committed Jan 6, 2025
1 parent d71328f commit 8cbe1c3
Show file tree
Hide file tree
Showing 6 changed files with 320 additions and 2 deletions.
73 changes: 73 additions & 0 deletions src/xunit.runner.visualstudio/Utility/AppDomainManager.cs
Original file line number Diff line number Diff line change
@@ -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<TObject>(
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
5 changes: 4 additions & 1 deletion src/xunit.runner.visualstudio/Utility/AssemblyExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System.Reflection;

#if NETFRAMEWORK
using System;
using System.IO;
using System.Reflection;
#endif

internal static class AssemblyExtensions
{
Expand Down
81 changes: 81 additions & 0 deletions src/xunit.runner.visualstudio/Utility/DiaSessionWrapper.cs
Original file line number Diff line number Diff line change
@@ -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<DiaSessionWrapperHelper>(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
}
}
115 changes: 115 additions & 0 deletions src/xunit.runner.visualstudio/Utility/DiaSessionWrapperHelper.cs
Original file line number Diff line number Diff line change
@@ -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<string, Type> 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<AsyncStateMachineAttribute>()?.StateMachineType;
if (stateMachineType is not null && stateMachineType.FullName is not null)
{
typeName = stateMachineType.FullName;
methodName = "MoveNext";
}
}
}
}
catch { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Threading.Tasks;
using Xunit.Runner.Common;
using Xunit.Sdk;

namespace Xunit.Runner.VisualStudio;

/// <summary>
/// An implementation of <see cref="ISourceInformationProvider"/> that will provide source information
/// when running inside of Visual Studio (via the DiaSession class).
/// </summary>
/// <param name="assemblyFileName">The assembly file name.</param>
/// <param name="diagnosticMessageSink">The message sink to send internal diagnostic messages to.</param>
internal class VisualStudioSourceInformationProvider(
string assemblyFileName,
DiagnosticMessageSink diagnosticMessageSink) :
LongLivedMarshalByRefObject, ISourceInformationProvider
{
static readonly SourceInformation EmptySourceInformation = new();

readonly DiaSessionWrapper session = new DiaSessionWrapper(assemblyFileName, diagnosticMessageSink);

/// <inheritdoc/>
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);
}

/// <inheritdoc/>
public ValueTask DisposeAsync()
{
session.Dispose();

return default;
}
}
4 changes: 3 additions & 1 deletion src/xunit.runner.visualstudio/VsTestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ async Task DiscoverTests<TVisitor>(
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;

Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 8cbe1c3

Please sign in to comment.