Skip to content

Commit 73d4d7d

Browse files
committed
Add source information (Fixes #3)
1 parent 84efd1b commit 73d4d7d

6 files changed

Lines changed: 242 additions & 4 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@
1212
<PackageVersion Include="xunit.extensibility.core" Version="2.9.3" />
1313
<PackageVersion Include="xunit.extensibility.execution" Version="2.9.3" />
1414
<PackageVersion Include="xunit.runner.utility" Version="2.9.3" />
15+
<PackageVersion Include="Mono.Cecil" Version="0.11.6" />
1516
</ItemGroup>
1617
</Project>

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ There are known limitations on the current support of MTP for xunit 2 which is p
2323
- RunSettings isn't supported. The XML-based configuration of VSTest (RunSettings) is not supported.
2424
- Limited support could be added based on https://github.com/xunit/visualstudio.xunit/blob/d693866207d8c1b3269d1b7f4f62211b82ba7835/src/xunit.runner.visualstudio/Utility/RunSettings.cs.
2525
- Tracking issue: https://github.com/Youssef1313/YTest.MTP.XUnit2/issues/2
26-
- Source information is currently missing.
27-
- Tracking issue: https://github.com/Youssef1313/YTest.MTP.XUnit2/issues/3
2826
- Attachments (both test-level and session-level) are not supported.
2927
- Tracking issue: https://github.com/Youssef1313/YTest.MTP.XUnit2/issues/4
3028
- `TestMethodIdentifierProperty` is missing the parameter types for parameterized tests.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
using System;
2+
using System.Collections;
3+
using System.Collections.Concurrent;
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Linq;
7+
using System.Reflection;
8+
using Mono.Cecil;
9+
using Mono.Cecil.Cil;
10+
using Mono.Cecil.Rocks;
11+
using Xunit;
12+
using Xunit.Abstractions;
13+
14+
namespace YTest.MTP.XUnit2;
15+
16+
// Mostly taken from https://github.com/xunit/xunit/blob/4ade48a7e65aa916a20b11d38da0ec127454bf80/src/xunit.v3.runner.common/Frameworks/CecilSourceInformationProvider.cs#L10
17+
18+
internal sealed class CecilSourceInformationProvider : ISourceInformationProvider
19+
{
20+
// 0xFEEFEE marks a "hidden" line, per https://mono-cecil.narkive.com/gFuvydFp/trouble-with-sequencepoint
21+
private const int SEQUENCE_POINT_HIDDEN_LINE = 0xFEEFEE;
22+
23+
private static readonly HashSet<byte[]> s_publicKeyTokensToSkip = new(
24+
[
25+
[0x50, 0xce, 0xbf, 0x1c, 0xce, 0xb9, 0xd0, 0x5e], // Mono
26+
[0x8d, 0x05, 0xb1, 0xbb, 0x7a, 0x6f, 0xdb, 0x6c], // xUnit.net
27+
], ByteArrayComparer.Instance);
28+
29+
private static readonly DefaultSymbolReaderProvider s_symbolProvider = new(throwIfNoSymbol: false);
30+
31+
private readonly ConcurrentBag<ModuleDefinition> _moduleDefinitions = [];
32+
private readonly ConcurrentDictionary<string, TypeDefinition> _typeDefinitions = [];
33+
34+
private CecilSourceInformationProvider(string assemblyFileName)
35+
{
36+
try
37+
{
38+
AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad;
39+
40+
AddAssembly(assemblyFileName);
41+
42+
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
43+
AddAssembly(assembly);
44+
}
45+
catch { }
46+
}
47+
48+
void AddAssembly(string assemblyFileName)
49+
{
50+
try
51+
{
52+
if (!File.Exists(assemblyFileName))
53+
return;
54+
55+
var moduleDefinition = ModuleDefinition.ReadModule(assemblyFileName);
56+
57+
// Exclude non-.NET assemblies
58+
if (moduleDefinition.Assembly is null)
59+
return;
60+
61+
// Exclude things with known public keys
62+
var name = moduleDefinition.Assembly.Name;
63+
if (name.HasPublicKey && s_publicKeyTokensToSkip.Contains(name.PublicKeyToken))
64+
return;
65+
66+
using var symbolReader = s_symbolProvider.GetSymbolReader(moduleDefinition, moduleDefinition.FileName);
67+
if (symbolReader is null)
68+
return;
69+
70+
moduleDefinition.ReadSymbols(symbolReader, throwIfSymbolsAreNotMaching: false);
71+
if (!moduleDefinition.HasSymbols)
72+
return;
73+
74+
_moduleDefinitions.Add(moduleDefinition);
75+
foreach (var typeDefinition in moduleDefinition.Types.Where(t => t.IsPublic))
76+
_typeDefinitions.TryAdd(typeDefinition.FullName, typeDefinition);
77+
}
78+
catch { }
79+
}
80+
81+
void AddAssembly(Assembly assembly)
82+
{
83+
if (!assembly.IsDynamic)
84+
AddAssembly(assembly.Location);
85+
}
86+
87+
/// <summary>
88+
/// Creates a source provider for the given test assembly.
89+
/// </summary>
90+
/// <param name="assemblyFileName">The test assembly filename</param>
91+
/// <remarks>
92+
/// This may return an instance of <see cref="NullSourceInformationProvider"/> if source information
93+
/// collection is turned off, or if the provided assembly does not exist on disk.
94+
/// </remarks>
95+
public static ISourceInformationProvider Create(string assemblyFileName)
96+
{
97+
if (!RunSettingsUtility.CollectSourceInformation)
98+
return EfficientNullSourceInformationProvider.Instance;
99+
100+
if (!File.Exists(assemblyFileName))
101+
return EfficientNullSourceInformationProvider.Instance;
102+
103+
return new CecilSourceInformationProvider(assemblyFileName);
104+
}
105+
106+
/// <inheritdoc/>
107+
public void Dispose()
108+
{
109+
try
110+
{
111+
AppDomain.CurrentDomain.AssemblyLoad -= OnAssemblyLoad;
112+
}
113+
catch { }
114+
115+
foreach (var moduleDefinition in _moduleDefinitions.Distinct())
116+
moduleDefinition?.Dispose();
117+
}
118+
119+
/// <inheritdoc/>
120+
public ISourceInformation GetSourceInformation(ITestCase testCase)
121+
{
122+
var testClassName = testCase.TestMethod.TestClass.Class.Name;
123+
var testMethodName = testCase.TestMethod.Method.Name;
124+
if (testClassName is null || testMethodName is null)
125+
return EfficientNullSourceInformationProvider.NullSourceInformation;
126+
127+
try
128+
{
129+
var testClassNamePieces = testClassName.Split('+');
130+
131+
if (_typeDefinitions.TryGetValue(testClassNamePieces[0], out var typeDefinition))
132+
{
133+
foreach (var nestedClassName in testClassNamePieces.Skip(1))
134+
{
135+
typeDefinition = typeDefinition.NestedTypes.FirstOrDefault(t => t.Name == nestedClassName);
136+
if (typeDefinition is null)
137+
return EfficientNullSourceInformationProvider.NullSourceInformation;
138+
}
139+
140+
var methodDefinitions = typeDefinition.GetMethods().Where(m => m.Name == testMethodName && m.IsPublic).ToList();
141+
if (methodDefinitions.Count == 1)
142+
{
143+
var debugInformation = typeDefinition.Module.SymbolReader.Read(methodDefinitions[0]);
144+
var sequencePoint = debugInformation.SequencePoints.FirstOrDefault(sp => sp.StartLine != SEQUENCE_POINT_HIDDEN_LINE);
145+
if (sequencePoint is not null)
146+
return new SourceInformation() { FileName = sequencePoint.Document.Url, LineNumber = sequencePoint.StartLine };
147+
}
148+
}
149+
}
150+
catch { }
151+
152+
return EfficientNullSourceInformationProvider.NullSourceInformation;
153+
}
154+
155+
void OnAssemblyLoad(
156+
object? sender,
157+
AssemblyLoadEventArgs args) =>
158+
AddAssembly(args.LoadedAssembly);
159+
160+
private sealed class ByteArrayComparer : IEqualityComparer<byte[]>
161+
{
162+
public static ByteArrayComparer Instance { get; } = new();
163+
164+
public bool Equals(byte[]? x, byte[]? y)
165+
{
166+
if (x is null)
167+
return y is null;
168+
if (y is null)
169+
return false;
170+
if (x.Length != y.Length)
171+
return false;
172+
173+
return ((IStructuralEquatable)x).Equals(y, EqualityComparer<byte>.Default);
174+
}
175+
176+
public int GetHashCode(byte[] obj) =>
177+
((IStructuralEquatable)obj).GetHashCode(EqualityComparer<byte>.Default);
178+
}
179+
180+
// xunit already has NullSourceInformationProvider but it allocates a new instance every time. This returns a cached instance.
181+
private sealed class EfficientNullSourceInformationProvider : LongLivedMarshalByRefObject, ISourceInformationProvider
182+
{
183+
private EfficientNullSourceInformationProvider()
184+
{
185+
}
186+
187+
public static ISourceInformation NullSourceInformation { get; } = new SourceInformation();
188+
189+
public static ISourceInformationProvider Instance { get; } = new EfficientNullSourceInformationProvider();
190+
191+
public ISourceInformation GetSourceInformation(ITestCase testCase)
192+
=> NullSourceInformation;
193+
194+
public void Dispose()
195+
{
196+
}
197+
}
198+
199+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System;
2+
using System.Collections;
3+
using System.Linq;
4+
using System.Xml.Linq;
5+
using System.Xml.XPath;
6+
7+
namespace YTest.MTP.XUnit2;
8+
9+
// Copy from https://github.com/xunit/xunit/blob/4ade48a7e65aa916a20b11d38da0ec127454bf80/src/xunit.v3.runner.common/Utility/RunSettingsUtility.cs
10+
11+
internal static class RunSettingsUtility
12+
{
13+
private static bool? s_collectSourceInformation;
14+
15+
public static bool CollectSourceInformation
16+
{
17+
get
18+
{
19+
if (!s_collectSourceInformation.HasValue)
20+
{
21+
try
22+
{
23+
var runSettings = Environment.GetEnvironmentVariable("TESTINGPLATFORM_EXPERIMENTAL_VSTEST_RUNSETTINGS");
24+
if (runSettings is not null)
25+
{
26+
var doc = XDocument.Parse(runSettings);
27+
if (doc.Root?.XPathEvaluate("/RunSettings/RunConfiguration/CollectSourceInformation") is IEnumerable enumerable)
28+
if (enumerable.OfType<XElement>().FirstOrDefault() is XElement element)
29+
s_collectSourceInformation = bool.Parse(element.Value);
30+
}
31+
}
32+
catch { }
33+
34+
s_collectSourceInformation ??= false;
35+
}
36+
37+
return s_collectSourceInformation.Value;
38+
}
39+
}
40+
}

src/YTest.MTP.XUnit2/MTPFramework/XUnit2MTPTestFramework.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,7 @@ private static XunitFrontController GetFrontController(string assemblyPath, Test
274274
configFileName: null,
275275
configuration.ShadowCopyOrDefault,
276276
shadowCopyFolder: null,
277-
// TODO: https://github.com/Youssef1313/YTest.MTP.XUnit2/issues/3
278-
sourceInformationProvider: null,
277+
sourceInformationProvider: CecilSourceInformationProvider.Create(assemblyPath),
279278
diagnosticMessageSink);
280279

281280
private MTPDiagnosticMessageSink GetDiagnosticMessageSink(string assemblyPath, TestAssemblyConfiguration configuration, CancellationToken cancellationToken)

src/YTest.MTP.XUnit2/YTest.MTP.XUnit2.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageReference Include="xunit.extensibility.core" />
1515
<PackageReference Include="xunit.extensibility.execution" />
1616
<PackageReference Include="xunit.runner.utility" />
17+
<PackageReference Include="Mono.Cecil" />
1718
</ItemGroup>
1819

1920
<ItemGroup>

0 commit comments

Comments
 (0)