diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AssemblyFileLocator.cs b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AssemblyFileLocator.cs new file mode 100644 index 0000000000..0939fe644b --- /dev/null +++ b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AssemblyFileLocator.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Helpers; + +/// +/// Centralizes access to so that single-file / Native AOT +/// scenarios (where Assembly.Location returns an empty string) are handled once instead +/// of being scattered across many call sites with individual IL3000 suppressions. +/// +/// +/// All members tolerate returning an empty string — that is the +/// documented behavior for assemblies embedded in single-file or Native AOT executables. +/// Callers receive (for try-style getters) or fall back to +/// / the assembly simple name when appropriate. +/// +internal static class AssemblyFileLocator +{ + /// + /// Returns the file path of the given assembly, or when the + /// assembly is embedded in a single-file or Native AOT executable (and therefore has no + /// on-disk location). + /// + [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "Empty return is the documented contract; callers are expected to handle null.")] + public static string? TryGetLocation(Assembly assembly) + { + string location = assembly.Location; + return string.IsNullOrEmpty(location) ? null : location; + } + + /// + /// Returns the directory containing the given assembly, falling back to + /// when is empty + /// (single-file / Native AOT). + /// + [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "Falls back to AppContext.BaseDirectory when Assembly.Location is empty (single-file/Native AOT case).")] + public static string GetDirectoryOrAppContextBase(Assembly assembly) + { + string location = assembly.Location; + if (!string.IsNullOrEmpty(location)) + { + string? directory = Path.GetDirectoryName(location); + if (!string.IsNullOrEmpty(directory)) + { + return directory; + } + } + + return AppContext.BaseDirectory; + } + + /// + /// Returns the file name (with extension) of the given assembly, falling back to the + /// assembly simple name with a .dll suffix when + /// is empty (single-file / Native AOT). + /// + [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "Falls back to assembly simple name when Assembly.Location is empty (single-file/Native AOT case).")] + public static string GetFileNameOrSimpleName(Assembly assembly) + { + string location = assembly.Location; + if (!string.IsNullOrEmpty(location)) + { + return Path.GetFileName(location); + } + + string? simpleName = assembly.GetName().Name; + return string.IsNullOrEmpty(simpleName) + ? string.Empty + : simpleName + ".dll"; + } +} diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/DataSerializationHelper.cs b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/DataSerializationHelper.cs index b848f88591..45f7f92133 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/DataSerializationHelper.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/DataSerializationHelper.cs @@ -35,6 +35,14 @@ internal static class DataSerializationHelper /// /// Data array to serialize. /// Serialized array. + /// + /// Thrown when the runtime does not support dynamic code generation + /// (RuntimeFeature.IsDynamicCodeSupported is ), + /// for example under Native AOT, Mono AOT on iOS, or Blazor WebAssembly AOT. + /// DataContract-based serialization relies on dynamic code generation, and in such + /// AOT/MTP scenarios the in-process ActualData reference is used instead, + /// so this method should never be reached at runtime. + /// [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:Members attributed with RequiresUnreferencedCode may break when trimming", Justification = DataContractSerializationJustification)] [UnconditionalSuppressMessage("Aot", "IL3050:Avoid calling members annotated with 'RequiresDynamicCodeAttribute' when publishing as Native AOT", Justification = DataContractSerializationJustification)] public static string?[]? Serialize(object?[]? data) @@ -44,6 +52,22 @@ internal static class DataSerializationHelper return null; } +#if NET + if (!RuntimeFeature.IsDynamicCodeSupported) + { + // Cross-process data serialization is not used when dynamic code generation is + // unavailable (Native AOT, Mono iOS AOT, Blazor WASM AOT, ...). In MTP mode, + // discovery sets ActualData (in-process reference) instead. Reaching this code + // means a vstest-style code path is being exercised in an AOT build, which is + // unsupported because DataContractJsonSerializer requires runtime code generation. + throw new NotSupportedException( + "MSTest data-source argument serialization is not supported when the runtime " + + "does not support dynamic code generation (Native AOT, Mono iOS AOT, Blazor " + + "WebAssembly AOT, ...). Use Microsoft.Testing.Platform (MTP) mode, where " + + "parameterized test arguments are passed in-process."); + } +#endif + string?[] serializedData = new string?[data.Length * 2]; for (int i = 0; i < data.Length; i++) { @@ -82,6 +106,14 @@ internal static class DataSerializationHelper /// /// Serialized data array to deserialize. /// Deserialized array. + /// + /// Thrown when the runtime does not support dynamic code generation + /// (RuntimeFeature.IsDynamicCodeSupported is ), + /// for example under Native AOT, Mono AOT on iOS, or Blazor WebAssembly AOT. + /// DataContract-based deserialization relies on dynamic code generation, and in such + /// AOT/MTP scenarios the in-process ActualData reference is used instead, + /// so this method should never be reached at runtime. + /// [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:Members attributed with RequiresUnreferencedCode may break when trimming", Justification = DataContractSerializationJustification)] [UnconditionalSuppressMessage("Aot", "IL3050:Avoid calling members annotated with 'RequiresDynamicCodeAttribute' when publishing as Native AOT", Justification = DataContractSerializationJustification)] public static object?[]? Deserialize(string?[]? serializedData) @@ -91,6 +123,20 @@ internal static class DataSerializationHelper return null; } +#if NET + if (!RuntimeFeature.IsDynamicCodeSupported) + { + // See note on Serialize: when dynamic code generation is unavailable + // (Native AOT, Mono iOS AOT, Blazor WASM AOT, ...), the execution path uses + // TestMethod.ActualData in MTP mode, so this branch should be unreachable. + throw new NotSupportedException( + "MSTest data-source argument deserialization is not supported when the runtime " + + "does not support dynamic code generation (Native AOT, Mono iOS AOT, Blazor " + + "WebAssembly AOT, ...). Use Microsoft.Testing.Platform (MTP) mode, where " + + "parameterized test arguments are passed in-process."); + } +#endif + int length = serializedData.Length / 2; object?[] data = new object?[length]; diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestSourceHost.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestSourceHost.cs index 60f818f82f..7ff1be7380 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestSourceHost.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestSourceHost.cs @@ -18,6 +18,9 @@ #if !WINDOWS_UWP using Microsoft.VisualStudio.TestTools.UnitTesting; #endif +#if NETFRAMEWORK || (NET && !WINDOWS_UWP) +using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Helpers; +#endif namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices; @@ -110,21 +113,32 @@ internal TestSourceHost(string sourceFileName, IRunSettings? runSettings, IAppDo public void SetupHost() { #if NET && !WINDOWS_UWP - List resolutionPaths = GetResolutionPaths(_sourceFileName, false); - - if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsInfoEnabled) + // When the runtime does not support dynamic code generation + // (RuntimeFeature.IsDynamicCodeSupported is false — Native AOT, Mono iOS AOT, + // Blazor WebAssembly AOT, ...), the host cannot load assemblies through + // reflection-style fallbacks, so the AssemblyResolver (which hooks + // AssemblyLoadContext.Resolving to load assemblies on demand) can never + // contribute anything useful. Skip its creation entirely; this also lets the + // trimmer drop GetResolutionPaths and the resolver itself from the published + // binary. + if (RuntimeFeature.IsDynamicCodeSupported) { - PlatformServiceProvider.Instance.AdapterTraceLogger.Info("DesktopTestSourceHost.SetupHost(): Creating assembly resolver with resolution paths {0}.", string.Join(',', resolutionPaths)); - } + List resolutionPaths = GetResolutionPaths(_sourceFileName, false); - var assemblyResolver = new AssemblyResolver(resolutionPaths); - if (TryAddSearchDirectoriesSpecifiedInRunSettingsToAssemblyResolver(assemblyResolver, Path.GetDirectoryName(_sourceFileName)!)) - { - _parentDomainAssemblyResolver = assemblyResolver; - } - else - { - assemblyResolver.Dispose(); + if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsInfoEnabled) + { + PlatformServiceProvider.Instance.AdapterTraceLogger.Info("DesktopTestSourceHost.SetupHost(): Creating assembly resolver with resolution paths {0}.", string.Join(',', resolutionPaths)); + } + + var assemblyResolver = new AssemblyResolver(resolutionPaths); + if (TryAddSearchDirectoriesSpecifiedInRunSettingsToAssemblyResolver(assemblyResolver, Path.GetDirectoryName(_sourceFileName)!)) + { + _parentDomainAssemblyResolver = assemblyResolver; + } + else + { + assemblyResolver.Dispose(); + } } #elif NETFRAMEWORK List resolutionPaths = GetResolutionPaths(_sourceFileName, VSInstallationUtilities.IsCurrentProcessRunningInPortableMode()); @@ -272,7 +286,7 @@ private void SetContext(string? source) #if WIN_UI if (StringEx.IsNullOrEmpty(dirName)) { - dirName = Path.GetDirectoryName(typeof(TestSourceHost).Assembly.Location)!; + dirName = AssemblyFileLocator.GetDirectoryOrAppContextBase(typeof(TestSourceHost).Assembly); } Directory.SetCurrentDirectory(dirName); @@ -326,8 +340,8 @@ internal string GetAppBaseAsPerPlatform() // there would be a mismatch of platform service assemblies during discovery. DebugEx.Assert(_targetFrameworkVersion is not null, "Target framework version is null."); return _targetFrameworkVersion.Contains(EngineConstants.DotNetFrameWorkStringPrefix) - ? Path.GetDirectoryName(_sourceFileName) ?? Path.GetDirectoryName(typeof(TestSourceHost).Assembly.Location) - : Path.GetDirectoryName(typeof(TestSourceHost).Assembly.Location); + ? Path.GetDirectoryName(_sourceFileName) ?? AssemblyFileLocator.GetDirectoryOrAppContextBase(typeof(TestSourceHost).Assembly) + : AssemblyFileLocator.GetDirectoryOrAppContextBase(typeof(TestSourceHost).Assembly); } internal string GetTargetFrameworkVersionString(string sourceFileName) @@ -350,7 +364,6 @@ internal string GetTargetFrameworkVersionString(string sourceFileName) /// /// A list of path. /// - [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "Assembly.Location is explicitly checked for empty before use; in single-file/Native AOT scenarios it returns empty and the affected blocks are skipped.")] internal virtual List GetResolutionPaths(string sourceFileName, bool isPortableMode) { List resolutionPaths = @@ -379,24 +392,22 @@ internal virtual List GetResolutionPaths(string sourceFileName, bool isP #endif } - // We check for the empty path, and in single file mode, or on source gen mode we don't allow - // loading dependencies than from the current folder, which is what the default loader handles by itself. - if (!string.IsNullOrEmpty(typeof(TestSourceHost).Assembly.Location)) + // AssemblyFileLocator.TryGetLocation returns null when Assembly.Location is empty + // (single-file / Native AOT). In those scenarios we don't add the adapter or test platform + // directories to the resolution paths and rely on the default loader resolving dependencies + // from the current folder, which is what it handles by itself in single-file mode. + string? adapterDirectory = Path.GetDirectoryName(AssemblyFileLocator.TryGetLocation(typeof(TestSourceHost).Assembly)); + if (!string.IsNullOrEmpty(adapterDirectory) && !resolutionPaths.Contains(adapterDirectory)) { // Adding adapter folder to resolution paths - if (!resolutionPaths.Contains(Path.GetDirectoryName(typeof(TestSourceHost).Assembly.Location)!)) - { - resolutionPaths.Add(Path.GetDirectoryName(typeof(TestSourceHost).Assembly.Location)!); - } + resolutionPaths.Add(adapterDirectory); } - if (!string.IsNullOrEmpty(typeof(AssemblyHelper).Assembly.Location)) + string? testPlatformDirectory = Path.GetDirectoryName(AssemblyFileLocator.TryGetLocation(typeof(AssemblyHelper).Assembly)); + if (!string.IsNullOrEmpty(testPlatformDirectory) && !resolutionPaths.Contains(testPlatformDirectory)) { // Adding TestPlatform folder to resolution paths - if (!resolutionPaths.Contains(Path.GetDirectoryName(typeof(AssemblyHelper).Assembly.Location)!)) - { - resolutionPaths.Add(Path.GetDirectoryName(typeof(AssemblyHelper).Assembly.Location)!); - } + resolutionPaths.Add(testPlatformDirectory); } return resolutionPaths; diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Utilities/DeploymentUtilityBase.cs b/src/Adapter/MSTestAdapter.PlatformServices/Utilities/DeploymentUtilityBase.cs index dd6918a934..be6335ba69 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Utilities/DeploymentUtilityBase.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Utilities/DeploymentUtilityBase.cs @@ -6,6 +6,7 @@ using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Deployment; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Extensions; +using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Helpers; using Microsoft.VisualStudio.TestPlatform.ObjectModel; using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; @@ -136,7 +137,6 @@ public static string GetTestResultsDirectory(IRunContext? runContext) => !String /// The deployment directory. /// Root results directory. /// Returns a list of deployment warnings. - [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "Deployment is a reflection-mode/legacy adapter feature; in single-file/Native AOT scenarios Assembly.Location returns an empty string and the comparison falls through without error.")] protected IEnumerable Deploy(IList deploymentItems, string testSourceHandler, string deploymentDirectory, string resultsDirectory) { Ensure.NotNullOrWhiteSpace(deploymentDirectory); @@ -194,8 +194,9 @@ protected IEnumerable Deploy(IList deploymentItems, stri // Ignore the test platform files. string tempFile = Path.GetFileName(fileToDeploy); - // We throw when we run in source gen mode. - string assemblyName = Path.GetFileName(GetType().Assembly.Location); + // Use AssemblyFileLocator to safely obtain the file name even when Assembly.Location + // is empty (single-file / Native AOT scenarios) by falling back to the simple name. + string assemblyName = AssemblyFileLocator.GetFileNameOrSimpleName(GetType().Assembly); if (tempFile.Equals(assemblyName, StringComparison.OrdinalIgnoreCase)) { continue;