diff --git a/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/SynchronizedSingleSessionVSTestAndTestAnywhereAdapter.cs b/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/SynchronizedSingleSessionVSTestAndTestAnywhereAdapter.cs index 0789698909..e2d903229e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/SynchronizedSingleSessionVSTestAndTestAnywhereAdapter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/SynchronizedSingleSessionVSTestAndTestAnywhereAdapter.cs @@ -150,7 +150,7 @@ protected sealed override Task ExecuteRequestAsync(TestExecutionRequest request, => ExecuteRequestWithRequestCountGuardAsync(async () => { #pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - string[] testAssemblyPaths = [.. _getTestAssemblies().Select(x => x.Location)]; + string[] testAssemblyPaths = [.. _getTestAssemblies().Select(GetAssemblyPath)]; #pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file switch (request) { @@ -179,6 +179,33 @@ public void Dispose() GC.SuppressFinalize(this); } + /// + /// Gets the path of an assembly, falling back to the assembly name when + /// returns an empty string (e.g. on Android CoreCLR + /// where assemblies are memory-mapped). + /// +#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file + internal static string GetAssemblyPath(Assembly assembly) + { +#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file + string location = assembly.Location; +#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file + if (!string.IsNullOrEmpty(location)) + { + return location; + } + + // On platforms like Android CoreCLR, assemblies may be memory-mapped and + // Assembly.Location returns an empty string. Use the assembly name as a + // synthetic path since the downstream code (on .NET Core) loads assemblies + // by name via Assembly.Load, not by file path. + string name = assembly.GetName().Name + ?? throw new InvalidOperationException($"Cannot determine the name of assembly '{assembly}'."); + + return name + ".dll"; + } +#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file + private async Task ExecuteRequestWithRequestCountGuardAsync(Func asyncFunc) { _incomingRequestCounter.AddCount(); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/SynchronizedSingleSessionVSTestBridgedTestFrameworkTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/SynchronizedSingleSessionVSTestBridgedTestFrameworkTests.cs new file mode 100644 index 0000000000..ec336f40d8 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/SynchronizedSingleSessionVSTestBridgedTestFrameworkTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Reflection; +using System.Reflection.Emit; + +using Moq; + +namespace Microsoft.Testing.Extensions.VSTestBridge.UnitTests; + +[TestClass] +public sealed class SynchronizedSingleSessionVSTestBridgedTestFrameworkTests +{ +#if NETCOREAPP + [TestMethod] + public void GetAssemblyPath_WhenLocationIsNonEmpty_ReturnsLocation() + { + // Arrange - Assembly.Location is virtual on .NET Core, so Moq can mock it + var assembly = new Mock(); + assembly.Setup(a => a.Location).Returns(@"C:\path\to\MyTests.dll"); + + // Act + string result = SynchronizedSingleSessionVSTestBridgedTestFramework.GetAssemblyPath(assembly.Object); + + // Assert + Assert.AreEqual(@"C:\path\to\MyTests.dll", result); + } + + [TestMethod] + public void GetAssemblyPath_WhenLocationIsEmpty_ReturnsSyntheticPathFromAssemblyName() + { + // Arrange - simulate Android CoreCLR where Assembly.Location returns "" + var assembly = new Mock(); + assembly.Setup(a => a.Location).Returns(string.Empty); + assembly.Setup(a => a.GetName()).Returns(new AssemblyName("MyTests")); + + // Act + string result = SynchronizedSingleSessionVSTestBridgedTestFramework.GetAssemblyPath(assembly.Object); + + // Assert + Assert.AreEqual("MyTests.dll", result); + } + + [TestMethod] + public void GetAssemblyPath_WhenLocationIsNull_ReturnsSyntheticPathFromAssemblyName() + { + // Arrange + var assembly = new Mock(); + assembly.Setup(a => a.Location).Returns((string)null!); + assembly.Setup(a => a.GetName()).Returns(new AssemblyName("MyTests")); + + // Act + string result = SynchronizedSingleSessionVSTestBridgedTestFramework.GetAssemblyPath(assembly.Object); + + // Assert + Assert.AreEqual("MyTests.dll", result); + } + + [TestMethod] + public void GetAssemblyPath_WhenLocationIsEmpty_AndAssemblyNameIsNull_Throws() + { + // Arrange + var assembly = new Mock(); + assembly.Setup(a => a.Location).Returns(string.Empty); + assembly.Setup(a => a.GetName()).Returns(new AssemblyName()); + + // Act & Assert + Assert.ThrowsExactly( + () => SynchronizedSingleSessionVSTestBridgedTestFramework.GetAssemblyPath(assembly.Object)); + } + + [TestMethod] + public void GetAssemblyPath_WithDynamicInMemoryAssembly_ReturnsSyntheticPath() + { + // Arrange - create a real in-memory assembly that has empty Location + var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("InMemoryTestAssembly"), + AssemblyBuilderAccess.Run); + + // Verify our assumption: dynamic assemblies have empty Location + Assert.AreEqual(string.Empty, assemblyBuilder.Location); + + // Act + string result = SynchronizedSingleSessionVSTestBridgedTestFramework.GetAssemblyPath(assemblyBuilder); + + // Assert + Assert.AreEqual("InMemoryTestAssembly.dll", result); + } +#endif + + [TestMethod] + public void GetAssemblyPath_WithRealAssembly_ReturnsActualLocation() + { + // Arrange - use the currently executing assembly which has a real file-backed location + Assembly assembly = typeof(SynchronizedSingleSessionVSTestBridgedTestFrameworkTests).Assembly; + + // Act + string result = SynchronizedSingleSessionVSTestBridgedTestFramework.GetAssemblyPath(assembly); + + // Assert - should return the real path ending with .dll or .exe + Assert.AreEqual(assembly.Location, result); + Assert.IsTrue( + result.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || result.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + } +}