Skip to content

Instantly share code, notes, and snippets.

@pakrym
Created September 11, 2017 22:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pakrym/cbe0c0be3742f4250660ea62614bb972 to your computer and use it in GitHub Desktop.
Save pakrym/cbe0c0be3742f4250660ea62614bb972 to your computer and use it in GitHub Desktop.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
[assembly: TestFramework("Microsoft.AspNetCore.AzureAppServices.FunctionalTests.Framework.VeryParallelFramework", "Microsoft.AspNetCore.AzureAppServices.FunctionalTests")]
namespace Microsoft.AspNetCore.AzureAppServices.FunctionalTests.Framework
{
class VeryParallelFramework: XunitTestFramework
{
public VeryParallelFramework(IMessageSink messageSink) : base(messageSink)
{
}
protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName)
{
return new VeryParallelTestFrameworkExecutor(assemblyName, SourceInformationProvider, DiagnosticMessageSink);
}
}
class VeryParallelTestFrameworkExecutor: XunitTestFrameworkExecutor
{
public VeryParallelTestFrameworkExecutor(AssemblyName assemblyName, ISourceInformationProvider sourceInformationProvider, IMessageSink diagnosticMessageSink) : base(assemblyName, sourceInformationProvider, diagnosticMessageSink)
{
}
protected override async void RunTestCases(IEnumerable<IXunitTestCase> testCases, IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions)
{
using (var assemblyRunner = new VeryParallelTestAssemblyRunner(TestAssembly, testCases, DiagnosticMessageSink, executionMessageSink, executionOptions))
await assemblyRunner.RunAsync();
}
}
class VeryParallelTestAssemblyRunner : XunitTestAssemblyRunner
{
public VeryParallelTestAssemblyRunner(ITestAssembly testAssembly, IEnumerable<IXunitTestCase> testCases, IMessageSink diagnosticMessageSink, IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) : base(testAssembly, testCases, diagnosticMessageSink, executionMessageSink, executionOptions)
{
}
protected override Task<RunSummary> RunTestCollectionAsync(IMessageBus messageBus, ITestCollection testCollection, IEnumerable<IXunitTestCase> testCases,
CancellationTokenSource cancellationTokenSource)
{
return new VeryParallelTestCollectionRunner(testCollection, testCases, DiagnosticMessageSink, messageBus, TestCaseOrderer, new ExceptionAggregator(Aggregator), cancellationTokenSource).RunAsync();
}
}
class VeryParallelTestCollectionRunner : XunitTestCollectionRunner
{
private readonly IMessageSink _diagnosticMessageSink;
public VeryParallelTestCollectionRunner(ITestCollection testCollection, IEnumerable<IXunitTestCase> testCases, IMessageSink diagnosticMessageSink, IMessageBus messageBus, ITestCaseOrderer testCaseOrderer, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) : base(testCollection, testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, cancellationTokenSource)
{
_diagnosticMessageSink = diagnosticMessageSink;
}
protected override Task<RunSummary> RunTestClassAsync(ITestClass testClass, IReflectionTypeInfo @class, IEnumerable<IXunitTestCase> testCases)
{
return new VeryParallelTestClassRunner(testClass, @class, testCases, _diagnosticMessageSink, MessageBus, TestCaseOrderer, new ExceptionAggregator(Aggregator), CancellationTokenSource, CollectionFixtureMappings).RunAsync();
}
}
class VeryParallelTestClassRunner : XunitTestClassRunner
{
public VeryParallelTestClassRunner(ITestClass testClass, IReflectionTypeInfo @class, IEnumerable<IXunitTestCase> testCases, IMessageSink diagnosticMessageSink, IMessageBus messageBus, ITestCaseOrderer testCaseOrderer, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource, IDictionary<Type, object> collectionFixtureMappings) : base(testClass, @class, testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, cancellationTokenSource, collectionFixtureMappings)
{
}
protected override async Task<RunSummary> RunTestMethodsAsync()
{
var summary = new RunSummary();
IEnumerable<IXunitTestCase> orderedTestCases;
try
{
orderedTestCases = TestCaseOrderer.OrderTestCases(TestCases);
}
catch (Exception ex)
{
var innerEx = ex.Unwrap();
DiagnosticMessageSink.OnMessage(new DiagnosticMessage($"Test case orderer '{TestCaseOrderer.GetType().FullName}' threw '{innerEx.GetType().FullName}' during ordering: {innerEx.Message}{Environment.NewLine}{innerEx.StackTrace}"));
orderedTestCases = TestCases.ToList();
}
var constructorArguments = CreateTestClassConstructorArguments();
foreach (var task in orderedTestCases.GroupBy(tc => tc.TestMethod, TestMethodComparer.Instance)
.Select(method => Task.Run(() => RunTestMethodAsync(method.Key, (IReflectionMethodInfo)method.Key.Method, method, constructorArguments))).ToArray())
{
var result = await task;
summary.Aggregate(result);
if (CancellationTokenSource.IsCancellationRequested)
break;
}
return summary;
}
protected override Task<RunSummary> RunTestMethodAsync(ITestMethod testMethod, IReflectionMethodInfo method, IEnumerable<IXunitTestCase> testCases, object[] constructorArguments)
{
return new VeryParallelTestMethodRunner(testMethod, Class, method, testCases, DiagnosticMessageSink, MessageBus, new ExceptionAggregator(Aggregator), CancellationTokenSource, constructorArguments).RunAsync();
}
}
class VeryParallelTestMethodRunner : XunitTestMethodRunner
{
public VeryParallelTestMethodRunner(ITestMethod testMethod, IReflectionTypeInfo @class, IReflectionMethodInfo method, IEnumerable<IXunitTestCase> testCases, IMessageSink diagnosticMessageSink, IMessageBus messageBus, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource, object[] constructorArguments) : base(testMethod, @class, method, testCases, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource, constructorArguments)
{
}
protected override async Task<RunSummary> RunTestCasesAsync()
{
var summary = new RunSummary();
foreach (var task in TestCases.Select(testCase => Task.Run(() => RunTestCaseAsync(testCase))).ToArray())
{
summary.Aggregate(await task);
if (CancellationTokenSource.IsCancellationRequested)
break;
}
return summary;
}
}
[XunitTestCaseDiscoverer("Microsoft.AspNetCore.AzureAppServices.FunctionalTests.Framework.VeryParallelTheoryDiscoverer", "Microsoft.AspNetCore.AzureAppServices.FunctionalTests")]
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class VeryParallelTheoryAttribute : FactAttribute { }
public class VeryParallelTheoryDiscoverer : TheoryDiscoverer
{
public VeryParallelTheoryDiscoverer(IMessageSink diagnosticMessageSink) : base(diagnosticMessageSink)
{
}
#pragma warning disable 672
protected override IXunitTestCase CreateTestCaseForDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow)
#pragma warning restore 672
{
return new VeryParallelTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod, dataRow);
}
#pragma warning disable 672
protected override IXunitTestCase CreateTestCaseForTheory(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute)
#pragma warning restore 672
{
return new VeryParallelTheoryTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod);;
}
}
class VeryParallelTestCase : XunitTestCase
{
#pragma warning disable CS0618 // Type or member is obsolete
public VeryParallelTestCase():base()
#pragma warning restore CS0618 // Type or member is obsolete
{
}
public VeryParallelTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, ITestMethod testMethod, object[] testMethodArguments = null) : base(diagnosticMessageSink, defaultMethodDisplay, testMethod, testMethodArguments)
{
}
public override Task<RunSummary> RunAsync(IMessageSink diagnosticMessageSink, IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator,
CancellationTokenSource cancellationTokenSource)
{
return new VeryParallelTheoryTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource).RunAsync();
}
}
class VeryParallelTheoryTestCase : XunitTheoryTestCase
{
#pragma warning disable CS0618 // Type or member is obsolete
public VeryParallelTheoryTestCase():base()
#pragma warning restore CS0618 // Type or member is obsolete
{
}
public VeryParallelTheoryTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, ITestMethod testMethod) : base(diagnosticMessageSink, defaultMethodDisplay, testMethod)
{
}
public override Task<RunSummary> RunAsync(IMessageSink diagnosticMessageSink, IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator,
CancellationTokenSource cancellationTokenSource)
{
return new VeryParallelTheoryTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource).RunAsync();
}
}
internal class VeryParallelTheoryTestCaseRunner: XunitTheoryTestCaseRunner
{
static readonly object[] NoArguments = new object[0];
readonly ExceptionAggregator cleanupAggregator = new ExceptionAggregator();
Exception dataDiscoveryException;
readonly IMessageSink diagnosticMessageSink;
readonly List<XunitTestRunner> testRunners = new List<XunitTestRunner>();
readonly List<IDisposable> toDispose = new List<IDisposable>();
/// <summary>
/// Initializes a new instance of the <see cref="XunitTheoryTestCaseRunner"/> class.
/// </summary>
/// <param name="testCase">The test case to be run.</param>
/// <param name="displayName">The display name of the test case.</param>
/// <param name="skipReason">The skip reason, if the test is to be skipped.</param>
/// <param name="constructorArguments">The arguments to be passed to the test class constructor.</param>
/// <param name="diagnosticMessageSink">The message sink used to send diagnostic messages</param>
/// <param name="messageBus">The message bus to report run status to.</param>
/// <param name="aggregator">The exception aggregator used to run code and collect exceptions.</param>
/// <param name="cancellationTokenSource">The task cancellation token source, used to cancel the test run.</param>
public VeryParallelTheoryTestCaseRunner(IXunitTestCase testCase,
string displayName,
string skipReason,
object[] constructorArguments,
IMessageSink diagnosticMessageSink,
IMessageBus messageBus,
ExceptionAggregator aggregator,
CancellationTokenSource cancellationTokenSource)
: base(testCase, displayName, skipReason, constructorArguments, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource)
{
this.diagnosticMessageSink = diagnosticMessageSink;
}
/// <inheritdoc/>
protected override async Task AfterTestCaseStartingAsync()
{
await base.AfterTestCaseStartingAsync();
try
{
var dataAttributes = TestCase.TestMethod.Method.GetCustomAttributes(typeof(DataAttribute));
foreach (var dataAttribute in dataAttributes)
{
var discovererAttribute = dataAttribute.GetCustomAttributes(typeof(DataDiscovererAttribute)).First();
var args = discovererAttribute.GetConstructorArguments().Cast<string>().ToList();
var discovererType = SerializationHelper.GetType(args[1], args[0]);
if (discovererType == null)
{
var reflectionAttribute = dataAttribute as IReflectionAttributeInfo;
if (reflectionAttribute != null)
Aggregator.Add(new InvalidOperationException($"Data discoverer specified for {reflectionAttribute.Attribute.GetType()} on {TestCase.TestMethod.TestClass.Class.Name}.{TestCase.TestMethod.Method.Name} does not exist."));
else
Aggregator.Add(new InvalidOperationException($"A data discoverer specified on {TestCase.TestMethod.TestClass.Class.Name}.{TestCase.TestMethod.Method.Name} does not exist."));
continue;
}
IDataDiscoverer discoverer;
try
{
discoverer = ExtensibilityPointFactory.GetDataDiscoverer(diagnosticMessageSink, discovererType);
}
catch (InvalidCastException)
{
var reflectionAttribute = dataAttribute as IReflectionAttributeInfo;
if (reflectionAttribute != null)
Aggregator.Add(new InvalidOperationException($"Data discoverer specified for {reflectionAttribute.Attribute.GetType()} on {TestCase.TestMethod.TestClass.Class.Name}.{TestCase.TestMethod.Method.Name} does not implement IDataDiscoverer."));
else
Aggregator.Add(new InvalidOperationException($"A data discoverer specified on {TestCase.TestMethod.TestClass.Class.Name}.{TestCase.TestMethod.Method.Name} does not implement IDataDiscoverer."));
continue;
}
var data = discoverer.GetData(dataAttribute, TestCase.TestMethod.Method);
if (data == null)
{
Aggregator.Add(new InvalidOperationException($"Test data returned null for {TestCase.TestMethod.TestClass.Class.Name}.{TestCase.TestMethod.Method.Name}. Make sure it is statically initialized before this test method is called."));
continue;
}
foreach (var dataRow in data)
{
toDispose.AddRange(dataRow.OfType<IDisposable>());
ITypeInfo[] resolvedTypes = null;
var methodToRun = TestMethod;
var convertedDataRow = methodToRun.ResolveMethodArguments(dataRow);
if (methodToRun.IsGenericMethodDefinition)
{
resolvedTypes = TestCase.TestMethod.Method.ResolveGenericTypes(convertedDataRow);
methodToRun = methodToRun.MakeGenericMethod(resolvedTypes.Select(t => ((IReflectionTypeInfo)t).Type).ToArray());
}
var parameterTypes = methodToRun.GetParameters().Select(p => p.ParameterType).ToArray();
convertedDataRow = Reflector.ConvertArguments(convertedDataRow, parameterTypes);
var theoryDisplayName = TestCase.TestMethod.Method.GetDisplayNameWithArguments(DisplayName, convertedDataRow, resolvedTypes);
var test = new XunitTest(TestCase, theoryDisplayName);
var skipReason = SkipReason ?? dataAttribute.GetNamedArgument<string>("Skip");
testRunners.Add(new VeryParallelRunner(test, MessageBus, TestClass, ConstructorArguments, methodToRun, convertedDataRow, skipReason, BeforeAfterAttributes, Aggregator, CancellationTokenSource));
}
}
}
catch (Exception ex)
{
// Stash the exception so we can surface it during RunTestAsync
dataDiscoveryException = ex;
}
}
/// <inheritdoc/>
protected override Task BeforeTestCaseFinishedAsync()
{
Aggregator.Aggregate(cleanupAggregator);
return base.BeforeTestCaseFinishedAsync();
}
/// <inheritdoc/>
protected override async Task<RunSummary> RunTestAsync()
{
if (dataDiscoveryException != null)
return RunTest_DataDiscoveryException();
var runSummary = new RunSummary();
foreach (var testRunner in testRunners)
runSummary.Aggregate(await testRunner.RunAsync());
// Run the cleanup here so we can include cleanup time in the run summary,
// but save any exceptions so we can surface them during the cleanup phase,
// so they get properly reported as test case cleanup failures.
var timer = new ExecutionTimer();
foreach (var disposable in toDispose)
timer.Aggregate(() => cleanupAggregator.Run(disposable.Dispose));
runSummary.Time += timer.Total;
return runSummary;
}
RunSummary RunTest_DataDiscoveryException()
{
var test = new XunitTest(TestCase, DisplayName);
if (!MessageBus.QueueMessage(new TestStarting(test)))
CancellationTokenSource.Cancel();
else if (!MessageBus.QueueMessage(new TestFailed(test, 0, null, dataDiscoveryException.Unwrap())))
CancellationTokenSource.Cancel();
if (!MessageBus.QueueMessage(new TestFinished(test, 0, null)))
CancellationTokenSource.Cancel();
return new RunSummary { Total = 1, Failed = 1 };
}
}
internal class VeryParallelRunner : XunitTestRunner
{
public VeryParallelRunner(ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, object[] testMethodArguments, string skipReason, IReadOnlyList<BeforeAfterTestAttribute> beforeAfterAttributes, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource)
: base(test, messageBus, testClass, FixOutpuHelper(constructorArguments), testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource)
{
}
private static object[] FixOutpuHelper(object[] constructorArguments)
{
var newArguments = new object[constructorArguments.Length];
Array.Copy(constructorArguments, newArguments, constructorArguments.Length);
for (int i = 0; i < newArguments.Length; i++)
{
var constructorArgument = constructorArguments[i];
if (constructorArgument is ITestOutputHelper)
{
constructorArguments[i] = new TestOutputHelper();
}
}
return newArguments;
}
protected override async Task<Tuple<decimal, string>> InvokeTestAsync(ExceptionAggregator aggregator)
{
var output = string.Empty;
TestOutputHelper testOutputHelper = null;
foreach (object obj in ConstructorArguments)
{
testOutputHelper = obj as TestOutputHelper;
if (testOutputHelper != null)
break;
}
if (testOutputHelper != null)
testOutputHelper.Initialize(MessageBus, Test);
var executionTime = await InvokeTestMethodAsync(aggregator);
if (testOutputHelper != null)
{
output = testOutputHelper.Output;
testOutputHelper.Uninitialize();
}
return Tuple.Create(executionTime, output);
}
}
static class SerializationHelper
{
/// <summary>
/// Converts an assembly qualified type name into a <see cref="Type"/> object.
/// </summary>
/// <param name="assemblyQualifiedTypeName">The assembly qualified type name.</param>
/// <returns>The instance of the <see cref="Type"/>, if available; <c>null</c>, otherwise.</returns>
public static Type GetType(string assemblyQualifiedTypeName)
{
var firstOpenSquare = assemblyQualifiedTypeName.IndexOf('[');
if (firstOpenSquare > 0)
{
var backtick = assemblyQualifiedTypeName.IndexOf('`');
if (backtick > 0 && backtick < firstOpenSquare)
{
// Run the string looking for the matching closing square brace. Can't just assume the last one
// is the end, since the type could be trailed by array designators.
var depth = 1;
var lastOpenSquare = firstOpenSquare + 1;
var sawNonArrayDesignator = false;
for (; depth > 0 && lastOpenSquare < assemblyQualifiedTypeName.Length; ++lastOpenSquare)
{
switch (assemblyQualifiedTypeName[lastOpenSquare])
{
case '[':
++depth;
break;
case ']':
--depth;
break;
case ',': break;
default:
sawNonArrayDesignator = true;
break;
}
}
if (sawNonArrayDesignator)
{
if (depth != 0) // Malformed, because we never closed what we opened
return null;
var genericArgument = assemblyQualifiedTypeName.Substring(
firstOpenSquare + 1, lastOpenSquare - firstOpenSquare - 2); // Strip surrounding [ and ]
var innerTypeNames =
SplitAtOuterCommas(genericArgument)
.Select(x => x.Substring(1, x.Length - 2)); // Strip surrounding [ and ] from each type name
var innerTypes = innerTypeNames.Select(s => GetType(s)).ToArray();
if (innerTypes.Any(t => t == null))
return null;
var genericDefinitionName = assemblyQualifiedTypeName.Substring(0, firstOpenSquare) +
assemblyQualifiedTypeName.Substring(lastOpenSquare);
var genericDefinition = GetType(genericDefinitionName);
if (genericDefinition == null)
return null;
// Push array ranks so we can get down to the actual generic definition
var arrayRanks = new Stack<int>();
while (genericDefinition.IsArray)
{
arrayRanks.Push(genericDefinition.GetArrayRank());
genericDefinition = genericDefinition.GetElementType();
}
var closedGenericType = genericDefinition.MakeGenericType(innerTypes);
while (arrayRanks.Count > 0)
{
var rank = arrayRanks.Pop();
closedGenericType = rank > 1 ? closedGenericType.MakeArrayType(rank) : closedGenericType.MakeArrayType();
}
return closedGenericType;
}
}
}
IList<string> parts = SplitAtOuterCommas(assemblyQualifiedTypeName, true);
return
parts.Count == 0 ? null : parts.Count == 1 ? Type.GetType(parts[0]) : GetType(parts[1], parts[0]);
}
/// <summary>
/// Converts an assembly name + type name into a <see cref="Type"/> object.
/// </summary>
/// <param name="assemblyName">The assembly name.</param>
/// <param name="typeName">The type name.</param>
/// <returns>The instance of the <see cref="Type"/>, if available; <c>null</c>, otherwise.</returns>
public static Type GetType(string assemblyName, string typeName)
{
#if XUNIT_FRAMEWORK // This behavior is only for v2, and only done on the remote app domain side
if (assemblyName.EndsWith(ExecutionHelper.SubstitutionToken, StringComparison.OrdinalIgnoreCase))
assemblyName =
assemblyName.Substring(0, assemblyName.Length - ExecutionHelper.SubstitutionToken.Length + 1) + ExecutionHelper.PlatformSuffix;
#endif
#if PLATFORM_DOTNET
Assembly assembly = null;
try
{
// Make sure we only use the short form
var an = new AssemblyName(assemblyName);
assembly = Assembly.Load(new AssemblyName { Name = an.Name, Version = an.Version });
}
catch { }
#else
// Support both long name ("assembly, version=x.x.x.x, etc.") and short name ("assembly")
var assembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.FullName == assemblyName || a.GetName().Name == assemblyName);
if (assembly == null)
{
try
{
assembly = Assembly.Load(assemblyName);
}
catch
{
}
}
#endif
if (assembly == null)
return null;
return assembly.GetType(typeName);
}
/// <summary>
/// Retrieves a substring from the string, with whitespace trimmed on both ends.
/// </summary>
/// <param name="str">The string.</param>
/// <param name="startIndex">The starting index.</param>
/// <param name="length">The length.</param>
/// <returns>
/// A substring starting no earlier than startIndex and ending no later
/// than startIndex + length.
/// </returns>
static string SubstringTrim(string str, int startIndex, int length)
{
int endIndex = startIndex + length;
while (startIndex < endIndex && char.IsWhiteSpace(str[startIndex]))
startIndex++;
while (endIndex > startIndex && char.IsWhiteSpace(str[endIndex - 1]))
endIndex--;
return str.Substring(startIndex, endIndex - startIndex);
}
static IList<string> SplitAtOuterCommas(string value, bool trimWhitespace = false)
{
var results = new List<string>();
var startIndex = 0;
var endIndex = 0;
var depth = 0;
for (; endIndex < value.Length; ++endIndex)
{
switch (value[endIndex])
{
case '[':
++depth;
break;
case ']':
--depth;
break;
case ',':
if (depth == 0)
{
results.Add(
trimWhitespace
? SubstringTrim(value, startIndex, endIndex - startIndex)
: value.Substring(startIndex, endIndex - startIndex));
startIndex = endIndex + 1;
}
break;
}
}
if (depth != 0 || startIndex >= endIndex)
{
results.Clear();
}
else
{
results.Add(
trimWhitespace ? SubstringTrim(value, startIndex, endIndex - startIndex) : value.Substring(startIndex, endIndex - startIndex));
}
return results;
}
}
internal static class ExceptionExtensions
{
/// <summary>
/// Unwraps an exception to remove any wrappers, like <see cref="TargetInvocationException"/>.
/// </summary>
/// <param name="ex">The exception to unwrap.</param>
/// <returns>The unwrapped exception.</returns>
public static Exception Unwrap(this Exception ex)
{
while (true)
{
var tiex = ex as TargetInvocationException;
if (tiex == null)
return ex;
ex = tiex.InnerException;
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment