Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ private FeatureFlag() { }
// Disable turning dynamic code coverage for native code to OFF by default. Setting this to 1 will skip adding the setting.
public const string VSTEST_DISABLE_DYNAMICNATIVE_CODECOVERAGE_DEFAULT_SETTING = nameof(VSTEST_DISABLE_DYNAMICNATIVE_CODECOVERAGE_DEFAULT_SETTING);

// Disable discovering the Code Coverage adapter from the microsoft.codecoverage NuGet package when --collect:"Code Coverage"
// runs against dlls, where the MSBuild task that normally provides the path did not run. Setting this to 1 will skip the discovery.
public const string VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY = nameof(VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY);

// Disable running Microsoft.Testing.Platform applications under vstest while the integration is experimental.
// This defaults to true. Set it to 0 to opt in to the feature.
public const string VSTEST_DISABLE_MTP_TESTHOST = nameof(VSTEST_DISABLE_MTP_TESTHOST);
Expand Down
269 changes: 269 additions & 0 deletions src/vstest.console/Processors/CollectArgumentProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security;
using System.Xml;

using Microsoft.VisualStudio.TestPlatform.CommandLine.Processors.Utilities;
Expand Down Expand Up @@ -81,6 +82,13 @@ internal class CollectArgumentExecutor : IArgumentExecutor
{
private readonly IRunSettingsProvider _runSettingsManager;
private readonly IFileHelper _fileHelper;

/// <summary>
/// The Code Coverage collector assembly, which marks the folder inside the
/// <c>microsoft.codecoverage</c> package that has to be handed to the run as an adapter path.
/// </summary>
private const string TraceDataCollectorAssemblyName = "Microsoft.VisualStudio.TraceDataCollector.dll";

internal static List<string> EnabledDataCollectors = new();
internal CollectArgumentExecutor(IRunSettingsProvider runSettingsManager, IFileHelper fileHelper)
{
Expand Down Expand Up @@ -116,6 +124,13 @@ public void Initialize(string? argument)
throw new SettingsException(string.Format(CultureInfo.CurrentCulture, CommandLineResources.CollectWithTestSettingErrorMessage, argument));
}
AddDataCollectorToRunSettings(collectArgumentList, _runSettingsManager, _fileHelper, exceptionMessage);

if (string.Equals(collectArgumentList[0], MicrosoftCodeCoverageConstants.FriendlyName, StringComparison.OrdinalIgnoreCase))
{
// In DLL mode the MSBuild task never runs, so VSTestTraceDataCollectorDirectoryPath is not set.
// Discover the adapter from NuGet instead. Scoped to --collect; --enable-code-coverage uses MSBuild.
TryAddCodeCoverageAdapterPath(_runSettingsManager);
Comment thread
nohwnd marked this conversation as resolved.
Comment thread
nohwnd marked this conversation as resolved.
}
}

/// <summary>
Expand Down Expand Up @@ -293,6 +308,260 @@ internal static void AddDataCollectorFriendlyName(string friendlyName)
EnabledDataCollectors.Add(friendlyName.ToLower(CultureInfo.CurrentCulture));
}

/// <summary>
/// Adds the Microsoft Code Coverage adapter path to the run settings, discovered from the
/// <c>microsoft.codecoverage</c> NuGet package. Does nothing when the package cannot be found.
/// Set <c>VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY=1</c> to turn the discovery off without
/// having to pass an adapter path.
/// </summary>
internal static void TryAddCodeCoverageAdapterPath(IRunSettingsProvider runSettingsManager, string? nugetPackagesOverride = null, IFeatureFlag? featureFlag = null)
{
if ((featureFlag ?? FeatureFlag.Instance).IsSet(FeatureFlag.VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY))
{
EqtTrace.Verbose("CollectArgumentExecutor.TryAddCodeCoverageAdapterPath: Discovery is disabled by VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY.");
return;
}

// A run that already has adapter paths does no discovery. A whitespace-only node counts as unset.
var existingPaths = TestAdapterPathArgumentExecutor.SplitPaths(
runSettingsManager.QueryRunSettingsNode(TestAdapterPathArgumentExecutor.RunSettingsPath));
if (existingPaths.Any(p => !p.IsNullOrWhiteSpace()))
{
return;
}

if (!TryGetCodeCoverageAdapterPath(out var ccAdapterPath, nugetPackagesOverride))
{
EqtTrace.Verbose("CollectArgumentExecutor.TryAddCodeCoverageAdapterPath: Code Coverage adapter path not found; skipping auto-injection.");
return;
}

runSettingsManager.UpdateRunSettingsNode(TestAdapterPathArgumentExecutor.RunSettingsPath, ccAdapterPath);
Comment thread
nohwnd marked this conversation as resolved.
EqtTrace.Verbose("CollectArgumentExecutor.TryAddCodeCoverageAdapterPath: Injected Code Coverage adapter path '{0}'.", ccAdapterPath);
}

/// <summary>
/// Finds the directory holding the collector of the installed <c>microsoft.codecoverage</c> package,
/// which is the same leaf folder the MSBuild path uses. Returns <see langword="false"/> when no
/// suitable package is found.
/// </summary>
/// <remarks>
/// This is not the same choice MSBuild makes. A project run injects the exact version the test project
/// references, while here there is no project to ask, so the highest installed version wins. Stable
/// releases are preferred over pre-releases so that a preview sitting in the package cache is only
/// picked when nothing else is installed. Pass <c>--testAdapterPath</c> to pin a specific one.
/// </remarks>
internal static bool TryGetCodeCoverageAdapterPath([NotNullWhen(true)] out string? path, string? nugetPackagesOverride = null)
{
path = null;

try
{
path = FindCodeCoverageAdapterPath(nugetPackagesOverride);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or SecurityException)
{
// Discovery is best effort; a packages folder we cannot read must not fail the run.
EqtTrace.Verbose("CollectArgumentExecutor.TryGetCodeCoverageAdapterPath: Could not inspect the NuGet global packages folder: {0}", ex);
}

return path is not null;
}

private static string? FindCodeCoverageAdapterPath(string? nugetPackagesOverride)
{
var nugetPackagesPath = nugetPackagesOverride ?? GetNuGetGlobalPackagesPath();
if (nugetPackagesPath is null)
{
return null;
}

var ccPackagePath = Path.Combine(nugetPackagesPath, "microsoft.codecoverage");
if (!Directory.Exists(ccPackagePath))
{
return null;
}

string? bestPath = null;
Version? bestVersion = null;
string? bestPreRelease = null;
string bestDirectoryName = string.Empty;

foreach (var versionDir in Directory.GetDirectories(ccPackagePath))
{
// Parse the folder name before touching the disk, a name that is not a version is not a
// candidate no matter what it holds.
var directoryName = Path.GetFileName(versionDir);
if (!TryParseNuGetVersion(directoryName, out var version, out var preRelease))
{
continue;
}

var collectorDir = FindCollectorDirectory(Path.Combine(versionDir, "build"));
if (collectorDir is null)
{
continue;
}

if (bestVersion is null
|| CompareNuGetVersions(version, preRelease, directoryName, bestVersion, bestPreRelease, bestDirectoryName) > 0)
{
bestVersion = version;
bestPreRelease = preRelease;
bestDirectoryName = directoryName;
bestPath = collectorDir;
}
}

return bestPath;
}

/// <summary>
/// Returns the directory that holds the Code Coverage collector, which sits in a target framework
/// folder under <c>build/</c>, e.g. <c>build/netstandard2.0</c>. Pointing at the folder that actually
/// holds the assembly matches what the MSBuild path injects, so the run does not depend on the default
/// adapter loading strategy searching directories recursively.
/// </summary>
private static string? FindCollectorDirectory(string buildDir)
{
if (!Directory.Exists(buildDir))
{
return null;
}

if (File.Exists(Path.Combine(buildDir, TraceDataCollectorAssemblyName)))
{
return buildDir;
}

// Real packages hold exactly one such folder. Order the candidates so the answer does not
// depend on the order the file system returns the directories in, descending so that a newer
// target framework wins if a package ever ships the collector under more than one.
return Directory.GetDirectories(buildDir)
.Where(d => File.Exists(Path.Combine(d, TraceDataCollectorAssemblyName)))
.OrderByDescending(d => Path.GetFileName(d), StringComparer.Ordinal)
.FirstOrDefault();
}

/// <summary>
/// Returns the NuGet global packages folder path, or <see langword="null"/> if it cannot be determined.
/// Checks the <c>NUGET_PACKAGES</c> environment variable first, then falls back to
/// <c>~/.nuget/packages</c>. A <c>globalPackagesFolder</c> set in <c>NuGet.Config</c> is <em>not</em>
/// consulted, because reading it means taking a dependency on the NuGet libraries; set
/// <c>NUGET_PACKAGES</c> or pass <c>--testAdapterPath</c> when the packages folder is configured that way.
/// </summary>
private static string? GetNuGetGlobalPackagesPath()
{
var envPath = Environment.GetEnvironmentVariable("NUGET_PACKAGES");
if (!envPath.IsNullOrEmpty())
{
return envPath;
}

var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return userProfile.IsNullOrEmpty() ? null : Path.Combine(userProfile, ".nuget", "packages");
Comment thread
nohwnd marked this conversation as resolved.
}

/// <summary>
/// Splits a NuGet folder name into numeric version and pre-release label, e.g. <c>18.5.0-preview-1</c>
/// into <c>18.5.0</c> and <c>preview-1</c>. Build metadata after <c>+</c> is ignored for ordering.
/// </summary>
private static bool TryParseNuGetVersion(string versionName, [NotNullWhen(true)] out Version? version, out string? preReleaseLabel)
{
version = null;
preReleaseLabel = null;

var metadataIndex = versionName.IndexOf('+');
var numericPart = metadataIndex >= 0 ? versionName.Substring(0, metadataIndex) : versionName;

var dashIndex = numericPart.IndexOf('-');
if (dashIndex >= 0)
{
preReleaseLabel = numericPart.Substring(dashIndex + 1);
numericPart = numericPart.Substring(0, dashIndex);
}

return Version.TryParse(numericPart, out version);
}

/// <summary>
/// Orders two package versions the way this discovery wants them: a stable release first, then the
/// numeric version, then the pre-release label per SemVer 2.0. Preferring stable over a higher
/// pre-release differs from plain SemVer ordering on purpose, so that a preview left in the package
/// cache does not take over a run that never referenced it. Equal candidates fall back to the folder
/// name so the result does not depend on directory order.
/// </summary>
private static int CompareNuGetVersions(
Version left, string? leftPreRelease, string leftName,
Version right, string? rightPreRelease, string rightName)
{
// A stable release wins even against a higher pre-release.
bool leftIsStable = leftPreRelease is null;
bool rightIsStable = rightPreRelease is null;
if (leftIsStable != rightIsStable)
{
return leftIsStable ? 1 : -1;
}

var versionComparison = left.CompareTo(right);
if (versionComparison != 0)
{
return versionComparison;
}

if (leftPreRelease is null || rightPreRelease is null)
{
// Both are stable, the mixed case already returned above.
return string.CompareOrdinal(leftName, rightName);
}

var preReleaseComparison = ComparePreReleaseLabels(leftPreRelease, rightPreRelease);
return preReleaseComparison != 0
? preReleaseComparison
: string.CompareOrdinal(leftName, rightName);
}

private static int ComparePreReleaseLabels(string left, string right)
{
var leftIdentifiers = left.Split('.');
var rightIdentifiers = right.Split('.');

for (int i = 0; i < Math.Min(leftIdentifiers.Length, rightIdentifiers.Length); i++)
{
var comparison = ComparePreReleaseIdentifiers(leftIdentifiers[i], rightIdentifiers[i]);
if (comparison != 0)
{
return comparison;
}
}

// When every shared identifier is equal, the label with more identifiers is the higher one.
return leftIdentifiers.Length.CompareTo(rightIdentifiers.Length);
}

private static int ComparePreReleaseIdentifiers(string left, string right)
{
var leftIsNumeric = int.TryParse(left, NumberStyles.None, CultureInfo.InvariantCulture, out var leftNumber);
var rightIsNumeric = int.TryParse(right, NumberStyles.None, CultureInfo.InvariantCulture, out var rightNumber);

return (leftIsNumeric, rightIsNumeric) switch
{
(true, true) => leftNumber.CompareTo(rightNumber),
// SemVer 2.0: numeric identifiers always sort below alphanumeric ones.
(true, false) => -1,
(false, true) => 1,
_ => string.CompareOrdinal(left, right),
};
}

internal static class MicrosoftCodeCoverageConstants
{
/// <summary>
/// Microsoft Code Coverage data collector friendly name.
/// </summary>
public const string FriendlyName = "Code Coverage";
}

internal static class CoverletConstants
{
/// <summary>
Expand Down
Loading
Loading