Fix "Code Coverage" collector not found with dotnet test *.dll - #16215
Fix "Code Coverage" collector not found with dotnet test *.dll#16215Jakub Jareš (nohwnd) wants to merge 10 commits into
Conversation
When running dotnet test with DLL paths (e.g. dotnet test *.dll --collect:"Code Coverage"), the MSBuild task that normally injects the Code Coverage adapter path via VSTestTraceDataCollectorDirectoryPath is never invoked, causing vstest.console to report 'Unable to find a datacollector with friendly name Code Coverage'. Fixes #15351 by auto-discovering the microsoft.codecoverage NuGet package from the global packages directory (~/.nuget/packages or $NUGET_PACKAGES) and injecting the build/ directory as a TestAdaptersPaths entry when --collect:"Code Coverage" is specified. This mirrors what the MSBuild task does for project-based test runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR improves vstest.console’s --collect:"Code Coverage" behavior for dotnet test *.dll scenarios (where the MSBuild task doesn’t run) by auto-discovering the Microsoft Code Coverage adapter from the NuGet global packages folder and injecting its build/ directory into RunConfiguration.TestAdaptersPaths.
Changes:
- Added Code Coverage adapter auto-discovery/injection logic to
CollectArgumentExecutor.AddDataCollectorToRunSettings. - Implemented NuGet global packages resolution and
microsoft.codecoverage“latest version” selection logic. - Updated/added unit tests to validate Code Coverage runsettings enablement and adapter path injection behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/vstest.console/Processors/CollectArgumentProcessor.cs | Adds auto-discovery of microsoft.codecoverage package build/ path and injects it into TestAdaptersPaths when collecting Code Coverage. |
| test/vstest.console.UnitTests/Processors/CollectArgumentProcessorTests.cs | Adds unit tests covering adapter path discovery, version selection, injection, and deduplication. |
| test/vstest.console.UnitTests/Processors/EnableCodeCoverageArgumentProcessorTests.cs | Makes tests resilient by asserting via XmlRunSettingsUtilities instead of exact XML string comparisons. |
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
Code Review — PR #16215
This PR fixes a real gap: dotnet test *.dll --collect:"Code Coverage" fails today because VSTestTask is never invoked and the adapter path is never injected. The overall approach (auto-discovering the NuGet global package) is sound. However, there are two correctness bugs and one undescribed behavioral change that need addressing before merge.
🔴 Finding 1 — Pre-release wins over stable (correctness)
File: CollectArgumentProcessor.cs → ParseNuGetVersion / TryGetCodeCoverageAdapterPath
ParseNuGetVersion strips the pre-release suffix then compares numerically:
18.5.0→Version(18,5,0)18.6.0-preview-1→ stripped →18.6.0→Version(18,6,0)→ wins
This violates NuGet SemVer: 18.6.0-preview-1 < 18.6.0. The test TryGetCodeCoverageAdapterPath_PreReleaseSuffixStripperPicksHigherNumerics locks in this incorrect behavior. A developer who inadvertently restores a preview alongside a stable release will silently get the preview adapter injected.
Fix: When numeric versions are equal after stripping, prefer the non-pre-release. When numerics differ, the higher number wins (current behavior is fine for that case).
🔴 Finding 2 — Injection bypasses explicit --testAdapterPath (correctness)
File: CollectArgumentProcessor.cs → TryAddCodeCoverageAdapterPath (line ~323–329)
See inline comment. The dedup guard only prevents adding the identical NuGet path twice. When the user explicitly passes --testAdapterPath /some/other/cc/path, the code appends the NuGet path alongside it — potentially loading two competing versions of the CC collector.
Fix: Skip auto-injection entirely when the run settings already have any TestAdaptersPaths set (i.e., the user has explicitly configured adapter discovery).
🟡 Finding 3 — --enable-code-coverage now also triggers NuGet injection (undescribed)
EnableCodeCoverageArgumentProcessor.Initialize() calls:
CollectArgumentExecutor.AddDataCollectorToRunSettings(FriendlyName, _runSettingsManager, _fileHelper);
// FriendlyName = "Code Coverage"Because of this PR's change, that call now also triggers TryAddCodeCoverageAdapterPath. The --enable-code-coverage flag (a separate CLI option from --collect) now silently injects the NuGet adapter path too — this isn't described anywhere in the PR.
The three EnableCodeCoverageArgumentProcessorTests tests were updated from exact-XML to semantic assertions precisely because the XML now contains the injected path. These tests would fail in a CI environment where the microsoft.codecoverage NuGet package is installed, but pass in an environment where it isn't — that's environment-dependent test behavior.
Fix: Either document this intentional broadening, or gate the injection on --collect:"Code Coverage" by checking whether the call originates from CollectArgumentExecutor.Initialize() vs. the EnableCodeCoverage path (e.g., by not calling TryAddCodeCoverageAdapterPath from EnableCodeCoverageArgumentProcessor — or by making TryAddCodeCoverageAdapterPath opt-in rather than auto-triggered from AddDataCollectorToRunSettings).
i️ Minor — build/ directory check does not verify the adapter DLL exists
TryGetCodeCoverageAdapterPath accepts any directory named build/ inside a version folder. An empty build/ directory — or one from a corrupted package — will be returned as valid. Consider checking for the existence of the actual .targets / .dll file within it (the name is stable across NuGet releases).
🧠 Reviewed by Expert Code Reviewer
🧠 Reviewed by Expert Code Reviewer 🧠
- Fix 1 (correctness): Stable release now beats pre-release when the numeric version is identical. ParseNuGetVersion encodes stability in the Version.Revision field (stable = int.MaxValue, pre-release = 0) so 18.5.0 > 18.5.0-preview-1 but 18.6.0-preview-1 > 18.5.0 as before, matching NuGet SemVer semantics. - Fix 2 (correctness): Skip NuGet adapter auto-injection when the run settings already contain any TestAdaptersPaths. Preserves user- configured adapter paths and avoids loading two competing CC adapter DLL versions side by side. - Fix 3 (scoping): Move TryAddCodeCoverageAdapterPath out of the general-purpose AddDataCollectorToRunSettings and into CollectArgumentExecutor.Initialize only. The auto-discovery is now intentionally limited to the --collect:"Code Coverage" CLI path; --enable-code-coverage (EnableCodeCoverageArgumentProcessor) no longer triggers it, since MSBuild handles the adapter path for that code path. Update tests: - Add TryGetCodeCoverageAdapterPath_StableBeatsPreReleaseWhenSameNumerics - Add TryAddCodeCoverageAdapterPath_SkipsInjection_WhenExplicitAdapterPathAlreadySet - Rename duplicate-path test to SkipsInjection_WhenAdapterPathAlreadyPresent - Remove AddDataCollectorToRunSettings_WithCodeCoverage_InjectsAdapterPath (injection no longer happens via AddDataCollectorToRunSettings) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Commit pushed:
|
|
Addressed all three review findings in the latest commit: Finding 1 — Stable beats pre-release on equal numerics: Finding 2 — Skip injection when explicit adapter paths present: Finding 3 — Scope injection to
|
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
Code Review — PR #16215 (iteration d7852db)
The PR Iteration Agent addressed all three findings from the prior review. Here is the verification:
✅ Finding 1 — Pre-release SemVer semantics (resolved)
ParseNuGetVersion now encodes stability in Version.Revision (stable = int.MaxValue, pre-release = 0):
18.5.0→Version(18,5,0, int.MaxValue)beats18.5.0-preview-1→Version(18,5,0, 0)✓18.6.0-preview-1→Version(18,6,0, 0)still beats18.5.0→Version(18,5,0, int.MaxValue)because 18.6 > 18.5 ✓
New test TryGetCodeCoverageAdapterPath_StableBeatsPreReleaseWhenSameNumerics covers the equal-numeric case. The approach is correct and well-commented.
✅ Finding 2 — Injection bypasses explicit --testAdapterPath (resolved)
TryAddCodeCoverageAdapterPath now returns early when existingPaths.Count > 0:
if (existingPaths.Count > 0)
{
// User explicitly configured adapter paths — don't clobber with auto-discovered NuGet path.
return;
}Execution order (TestAdapterPath priority 9 → CollectArgumentExecutor priority 11) means user-supplied --testAdapterPath is already visible at injection time. New test TryAddCodeCoverageAdapterPath_SkipsInjection_WhenExplicitAdapterPathAlreadySet verifies this. ✓
✅ Finding 3 — Scope limited to --collect path only (resolved)
TryAddCodeCoverageAdapterPath is now exclusively called from CollectArgumentExecutor.Initialize, guarded by a friendly-name check:
if (string.Equals(collectArgumentList[0], MicrosoftCodeCoverageConstants.FriendlyName, StringComparison.OrdinalIgnoreCase))
{
TryAddCodeCoverageAdapterPath(_runSettingsManager);
}EnableCodeCoverageArgumentProcessor calls the static AddDataCollectorToRunSettings directly (not Initialize), so it is unaffected. ✓
[Description] Minor inaccuracy
The PR description states: "Added auto-discovery logic to CollectArgumentExecutor.AddDataCollectorToRunSettings". The final implementation places the logic in CollectArgumentExecutor.Initialize — the iteration commit deliberately moved it out of AddDataCollectorToRunSettings to fix Finding 3. The PR description was not updated to reflect the iteration changes. This is low-impact (the body comment by the iteration agent covers the changes), but worth a quick edit for historical accuracy.
No new issues were introduced in the iteration commit. The implementation is correct, the version-picking logic is well-documented, and all three fixes have targeted tests. Ready for human review.
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
The stability marker was encoded into Version.Revision. That throws ArgumentOutOfRangeException on a two-component folder name such as "18.5", because Version.TryParse leaves Build at -1, and it overwrites the real revision of a four-component version, so 18.5.0.1 and 18.5.0.2 compare equal. Two pre-releases of the same numeric version also still tied, so the winner depended on the order Directory.GetDirectories returned them in. Keep the parsed Version intact and carry the pre-release label alongside it. Compare numeric version first, then stable above pre-release, then the label per SemVer 2.0, then the folder name as a final tiebreaker. 🤖
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/vstest.console/Processors/CollectArgumentProcessor.cs:381
TryGetCodeCoverageAdapterPathenumerates the NuGet package directory withDirectory.GetDirectories(ccPackagePath)without handling IO/permission failures. If the global packages folder exists but cannot be enumerated (e.g. access denied, transient IO error), this will throw and abort--collect:"Code Coverage"processing instead of silently no-oping as intended.
foreach (var versionDir in Directory.GetDirectories(ccPackagePath))
{
var buildDir = Path.Combine(versionDir, "build");
if (!Directory.Exists(buildDir))
{
test/vstest.console.UnitTests/Processors/CollectArgumentProcessorTests.cs:13
- The newly added
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;is unused in this test file and is likely to trip IDE0005 (unnecessary using) in CI (warnings-as-errors).
using Microsoft.VisualStudio.TestPlatform.Common;
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
src/vstest.console/Processors/CollectArgumentProcessor.cs:321
TryAddCodeCoverageAdapterPathtreats any non-emptyTestAdaptersPathsinner text as an explicitly configured adapter path. If the runsettings contains only whitespace/newlines in<TestAdaptersPaths>(common with formatted XML),IsNullOrEmpty()returns false and injection is skipped even though there are no real paths.
var existingPathsRaw = runSettingsManager.QueryRunSettingsNode(TestAdapterPathArgumentExecutor.RunSettingsPath);
var existingPaths = existingPathsRaw.IsNullOrEmpty()
? []
: existingPathsRaw.Split(';').Where(p => !p.IsNullOrEmpty()).ToList();
There was a problem hiding this comment.
Code Review — PR #16215 (iteration 60a96ac)
This iteration refactors the version-comparison logic from the Version.Revision stability-encoding hack into a proper two-field (Version numeric, string? preRelease) approach with a full SemVer 2.0 comparator. All three findings from the prior review and all observations from the iteration review remain resolved.
✅ Prior findings — still resolved
- Finding 1 (pre-release SemVer semantics):
CompareNuGetVersionsnow handles every case correctly — numeric-major wins, then stable beats pre-release at equal numerics, then SemVer 2.0 label ordering, then ordinal folder-name tiebreaker. ✓ - Finding 2 (injection bypasses explicit
--testAdapterPath):existingPaths.Count > 0guard still in place. ✓ - Finding 3 (scope limited to
--collectpath):TryAddCodeCoverageAdapterPathcalled only fromInitialize, guarded by theFriendlyNameequality check. ✓
New edge cases handled in this iteration
- Two-component folder names (
18.5) —Version.TryParsesucceeds;18.5sorts below18.5.0(Build -1 < 0), consistent with NuGet. ✓ - Four-component versions (
18.5.0.1,18.5.0.2) —Version.CompareTouses all four components; no longer overwritten. ✓ - Build metadata (
18.6.0+abc123) — stripped before parsing; treated as stable18.6.0. ✓ - Same-numerics two pre-releases (
18.6.0-preview-1vs18.6.0-preview-2) — SemVer 2.0ComparePreReleaseLabels; deterministic regardless ofDirectory.GetDirectoriesorder. ✓ - Dotted numeric pre-release identifiers (
rc.2vsrc.10) —ComparePreReleaseIdentifiersparses withNumberStyles.Noneso10 > 2. ✓
🔵 Minor — filesystem I/O before cheap early-exit guard
See inline comment at line 312. TryGetCodeCoverageAdapterPath (three I/O calls) runs before existingPaths.Count > 0 is checked. Reordering costs nothing in the common case. Not a correctness issue.
[Description] Still inaccurate (carry-over from prior review)
The PR description still says "Added auto-discovery logic to CollectArgumentExecutor.AddDataCollectorToRunSettings". The logic lives in Initialize, not in AddDataCollectorToRunSettings. The prior review noted this; it hasn't been updated. Low-impact but worth a quick edit for historical accuracy.
The SemVer 2.0 comparator is correct and well-tested. Ready for human review.
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
Move the TestAdaptersPaths guard ahead of the NuGet lookup, so a run that already has adapter paths does no file system work, and treat a node that holds nothing but whitespace as unset. Wrap the package discovery so an unreadable global packages folder or an unusable NUGET_PACKAGES value reports 'not found' instead of throwing out of Initialize. ArgumentException matters on .NET Framework, where Path.Combine rejects invalid characters. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
The Whitespace only On the description note: the body already says Release build is clean and 🤖 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/vstest.console/Processors/CollectArgumentProcessor.cs:316
- The check for pre-existing adapter paths hardcodes ';' and duplicates parsing logic. Using the existing
TestAdapterPathArgumentExecutor.SplitPathshelper keeps path splitting behavior consistent (and centralizes the separator definition) while preserving the intended whitespace-only behavior.
var existingPathsRaw = runSettingsManager.QueryRunSettingsNode(TestAdapterPathArgumentExecutor.RunSettingsPath);
if (!existingPathsRaw.IsNullOrWhiteSpace() && existingPathsRaw.Split(';').Any(p => !p.IsNullOrWhiteSpace()))
src/vstest.console/Processors/CollectArgumentProcessor.cs:349
TryGetCodeCoverageAdapterPathis intended to be best-effort, but it does not catchSecurityException/NotSupportedException, which can be thrown by file system/path APIs and would crash--collect:"Code Coverage". Other argument processors (e.g., ResultsDirectoryArgumentExecutor) explicitly handle these cases.
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
// Auto-discovery is best effort. A packages folder we are not allowed to read, a transient
// IO error, or an unusable NUGET_PACKAGES value must not take down the whole run.
EqtTrace.Verbose("CollectArgumentExecutor.TryGetCodeCoverageAdapterPath: Could not inspect the NuGet global packages folder: {0}", ex);
}
src/vstest.console/Processors/CollectArgumentProcessor.cs:326
- This verbose trace implies the NuGet package is missing, but
TryGetCodeCoverageAdapterPathcan also fail for other reasons (e.g., nobuild/subdirectory, unreadable directories, or unparseable version folders). Wording it as "adapter path not found" is more accurate for diagnostics.
{
EqtTrace.Verbose("CollectArgumentExecutor.TryAddCodeCoverageAdapterPath: Microsoft.CodeCoverage package not found in NuGet global packages; skipping auto-injection.");
return;
test/vstest.console.UnitTests/Processors/CollectArgumentProcessorTests.cs:13
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;appears to be unused in this test file (and can trigger IDE0005/unnecessary-using failures in CI). Please remove it if it's not needed.
using Microsoft.VisualStudio.TestPlatform.CommandLine.Processors;
using Microsoft.VisualStudio.TestPlatform.CommandLine.UnitTests;
using Microsoft.VisualStudio.TestPlatform.Common;
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
There was a problem hiding this comment.
Code Review — PR #16215 (iteration 63f19f5)
The two open findings from the prior review have been addressed in commit 63f19f5:
✅ Performance: guard before I/O (resolved)
TryAddCodeCoverageAdapterPath now reads TestAdaptersPaths from run settings and returns early before touching the filesystem when a non-empty, non-whitespace path is already configured. The Split(';').Any(p => !p.IsNullOrWhiteSpace()) check correctly treats a whitespace-only XML node (e.g. a pretty-printed empty <TestAdaptersPaths> element) as "not configured", and the new TryInjects_WhenExistingAdapterPathsNodeIsOnlyWhitespace test validates exactly that.
✅ Exception handling: ArgumentException on .NET Framework (resolved)
TryGetCodeCoverageAdapterPath now wraps FindCodeCoverageAdapterPath in a catch for IOException | UnauthorizedAccessException | ArgumentException, so an invalid NUGET_PACKAGES value with characters that Path.Combine rejects on .NET Framework returns false rather than propagating out of Initialize. The new TryGetCodeCoverageAdapterPath_ReturnsFalse_WhenNuGetPackagesPathIsUnusable test exercises this path with "|:invalid\0path".
✅ PR description accuracy (resolved)
The description correctly says CollectArgumentExecutor.Initialize owns the new logic, the existing-paths guard is accurately described, and the --enable-code-coverage scope exclusion matches the code.
No new findings
The full diff has been reviewed across all dimensions. The algorithmic correctness, version-comparison logic, resource management, and test coverage are all sound. No issues were found.
🧠 Reviewed by Expert Code Reviewer
🧠 Reviewed by Expert Code Reviewer 🧠
Split the configured adapter paths with TestAdapterPathArgumentExecutor.SplitPaths instead of hardcoding the separator again, so the definition stays in one place. Add NotSupportedException and SecurityException to the discovery catch list, which matches what ResultsDirectoryArgumentExecutor already handles for path APIs, and reword the trace so it does not claim the package is missing when discovery failed for another reason. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Hardcoded
The trace claims the package is missing when discovery can fail for other reasons. Taken, it says the adapter path was not found now. The Release build clean, 🤖 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
test/vstest.console.UnitTests/Processors/CollectArgumentProcessorTests.cs:13
- The
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;directive appears unused in this test file and will trigger IDE0005 (unnecessary using) in builds that treat analyzer warnings as errors.
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;
Trim the chatty comments added during review to the non-obvious why: the DLL-mode reason, best-effort discovery, and the SemVer ordering rules. No behavior change. 🤖 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
fhnaseer if this is good idea please approve. |
There was a problem hiding this comment.
Review
| Dimension | Status |
|---|---|
| RunSettings Validation & Inference | ✅ |
| Environment Variable & Feature Flag Contracts | |
| Backward Compatibility & Rollback Safety | |
| Null Safety & Boundary Validation | ✅ |
| Error Reporting & Diagnostic Clarity | ✅ |
| Algorithmic Correctness (version comparison) | ✅ |
| Acceptance Test Coverage |
Both July 5 correctness bugs are fixed. The CompareNuGetVersions implementation is clean: numeric version first, stable beats pre-release of identical numerics, SemVer 2.0 label comparison by dot-separated segments, ordinal folder name as final tiebreaker. TryParseNuGetVersion correctly preserves all four Version components and avoids the ArgumentOutOfRangeException the earlier Revision-encoding approach had on two-component folder names. The explicit path guard short-circuits before any filesystem I/O. Good.
Two things worth addressing before merge:
-
Kill switch (inline comment at line 125) —
--testAdapterPathis a functional opt-out but not a discoverable one. If auto-discovery picks the wrong version (or the right package but a broken build), there's noVSTEST_DISABLE_*env var to reach for. One-line fix. -
No acceptance test for the motivating scenario —
CodeCoverageTests.cshas no test that runsdotnet test *.dll --collect:"Code Coverage"and asserts the collector is found. This is the exact regression that prompted the PR; without an acceptance test it can regress silently. TheCollectArgumentProcessorTestsunit tests are comprehensive for the discovery logic, but they don't cover the end-to-end path fromInitializethrough adapter loading. -
NuGet.Configgap (inline comment at line 404) — minor; just a doc comment ask so the limitation is visible.
🧠 Reviewed by Expert Code Reviewer 🧠
fhnaseer
left a comment
There was a problem hiding this comment.
A couple of observations from reviewing the discovery path, both non-blocking,
Discovery picked the highest numeric version, so a preview left in the package cache won over the stable release, which is not what a project run would have used. It also injected build/, and the collector actually sits in a target framework folder below that, so it only resolved because the default adapter loading strategy searches recursively. Prefer a stable version unless nothing else is installed, and return the folder that holds Microsoft.VisualStudio.TraceDataCollector.dll, which is the same leaf folder the MSBuild path uses. Add VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY so the discovery can be turned off without passing an adapter path, and write down that NuGet.Config globalPackagesFolder is not consulted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both sides added a feature flag constant in the same place, kept both. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Code Review — PR #16215 (re-review)
The three findings from the previous review have all been addressed in the current revision:
- Pre-release over stable —
CompareNuGetVersionsnow explicitly places any stable release above any pre-release, regardless of numeric version. ✅ --testAdapterPathbypass —TryAddCodeCoverageAdapterPathchecks for existing paths, and becauseTestAdapterPathArgumentProcessorruns at priority 9 (beforeCollectArgumentProcessorat priority 11), by the time the guard executes the user's explicit paths are already in run settings. ✅--enable-code-coveragetriggering injection —TryAddCodeCoverageAdapterPathis now called only fromCollectArgumentExecutor.Initialize(), not from insideAddDataCollectorToRunSettings.EnableCodeCoverageArgumentProcessorcalls the single-string overload, which delegates to the array overload without callingTryAddCodeCoverageAdapterPath. ✅
Minor observation — I/O before version parsing in FindCodeCoverageAdapterPath
foreach (var versionDir in Directory.GetDirectories(ccPackagePath))
{
var collectorDir = FindCollectorDirectory(Path.Combine(versionDir, "build")); // I/O here
if (collectorDir is null)
continue;
var directoryName = Path.GetFileName(versionDir);
if (!TryParseNuGetVersion(directoryName, out var version, out var preRelease)) // cheap
continue;
...
}The directory I/O (FindCollectorDirectory) is performed before the cheap version string parsing (TryParseNuGetVersion). For directories with unparseable names that happen to contain the collector DLL, this wastes a filesystem probe. Given that microsoft.codecoverage typically has only a handful of version entries and all names should be valid SemVer, this has no practical impact — just noting the inversion.
Minor observation — EnableCodeCoverageArgumentProcessorTests assertion coverage
The three tests were changed from asserting the exact serialised XML to asserting individual collector properties. Since EnableCodeCoverageArgumentProcessor does not trigger injection, the XML structure itself is unchanged and the old assertions should still hold. The looser assertions reduce the signal the tests provide: if a future change introduces an unexpected extra node via the Enable path, the tests would not catch it. Not a blocker — just something to be aware of when future changes touch the Enable path.
Overall the implementation is correct and well-tested. The version-selection logic is thorough (stable-over-prerelease, numeric comparison, dotted pre-release identifiers, build-metadata stripping), the guard against double-injection is correctly positioned given processor priorities, and the escape hatch (VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY=1) and --testAdapterPath override are properly honoured.
🧠 Reviewed by expert-reviewing
🧠 Reviewed by Expert Code Reviewer 🧠
The three tests in EnableCodeCoverageArgumentProcessorTests were loosened from comparing the whole run settings xml to checking single collector properties, back when the injection still went through AddDataCollectorToRunSettings and changed the xml on the --enable-code-coverage path too. That call moved to CollectArgumentExecutor.Initialize, so that path produces the same xml as before and the original assertions hold again. Restoring them puts the signal back, and leaves the change to the three files it actually touches. Also parse the folder name before probing the disk in FindCodeCoverageAdapterPath, a folder whose name is not a version is not a candidate no matter what is in it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
I/O before version parsing. Taken. The Restoring the original assertions, they pass unchanged, which is also the proof that the enable path really does produce the same xml as before. That drops the file from the change, so this is now three files and purely additive, 789 insertions and no deletions. Release build clean, 🤖 |
There was a problem hiding this comment.
Code Review — PR #16215
Dimensions checked: RunSettings Validation & Inference · Environment Variable & Feature Flag Contracts · Backward Compatibility & Rollback Safety · Null Safety & Boundary Validation · Process Architecture & Host Resolution
What was verified
Priority ordering is correct. TestAdapterPath runs at priority 9, AutoUpdateRunSettings (which includes CollectArgumentProcessor) runs at priority 11. Lower number = processed first, so any --testAdapterPath supplied on the command line is already in RunSettings by the time TryAddCodeCoverageAdapterPath checks existingPaths.Any(...). The guard reliably skips auto-injection when the user has provided an explicit path.
TestAdapterPathArgumentExecutor.Initialize appends, not replaces. It reads existing RunSettings paths and merges them with the new argument before writing back. So even if both injection and --testAdapterPath end up running, the resulting TestAdaptersPaths holds both, and .Distinct() deduplicates.
RunSettings populated from a .runsettings file are also respected. The RunSettings processor runs at priority 6 (before both TestAdapterPath and Collect), so file-configured adapter paths are visible at injection time.
Exception handling is appropriate. TryGetCodeCoverageAdapterPath catches the full set of I/O-related exceptions (IOException, UnauthorizedAccessException, ArgumentException, NotSupportedException, SecurityException), making discovery best-effort without leaking failures into the test run.
Feature flag follows established naming convention. VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY is consistent with the VSTEST_DISABLE_* pattern used by other flags in FeatureFlag.cs. FeatureFlag is internal, so no PublicAPI.Unshipped.txt entry is required.
Version comparison logic is correct. The stable-over-pre-release preference, Version.CompareTo for numeric parts, and SemVer-2.0-style dotted pre-release identifier comparison all behave as documented. The CompareNuGetVersions branch if (leftPreRelease is null || rightPreRelease is null) is logically equivalent to if (leftPreRelease is null) at that point (the mixed stable/pre-release case returns earlier), but is not a bug.
PR description matches the diff. Title, description, and code changes are aligned. The documented escape hatch (VSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY=1) and skip conditions (no-op when --testAdapterPath is set, best-effort on I/O errors) are all present in the implementation.
Findings
One observation is noted inline (TFM sort direction in FindCollectorDirectory). No blocking issues found.
🧠 Reviewed by expert-reviewer (RunSettings / Feature Flag / Backward Compat / Null Safety / Process Arch dimensions) · workflow run
🧠 Reviewed by Expert Code Reviewer 🧠
Ordering the candidate folders ascending meant netstandard1.0 would win over netstandard2.0 if a package ever shipped the collector under both. No package does today, but descending is the preference we want, and it costs nothing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
test/vstest.console.UnitTests/Processors/CollectArgumentProcessorTests.cs:15
- Unused using directive
Microsoft.VisualStudio.TestPlatform.Common.Utilities(IDE0005 risk when warnings are treated as errors). Remove it to keep the usings clean and avoid CI failures.
using Microsoft.VisualStudio.TestPlatform.Common;
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.Utilities;
using Microsoft.VisualStudio.TestPlatform.Utilities.Helpers.Interfaces;
There was a problem hiding this comment.
Code Review — PR #16215 (follow-up pass)
Dimensions checked: RunSettings Validation & Inference · Environment Variable & Feature Flag Contracts · Backward Compatibility & Rollback Safety · Null Safety & Boundary Validation · Process Architecture & Host Resolution
What was reviewed
This is a follow-up pass after the earlier review (previous thread resolved). The previous blocking finding — non-deterministic version selection when two entries parsed to the same base Version — has been addressed: CompareNuGetVersions now applies a stable-beats-prerelease rule, then numeric comparison, then pre-release label comparison via ComparePreReleaseLabels/ComparePreReleaseIdentifiers, and falls back to an ordinal folder-name tiebreaker. Test coverage for all those cases is thorough.
Findings
Argument-processor priority ordering — ✅ verified correct. TestAdapterPathArgumentExecutor has priority 9 (TestAdapterPath); CollectArgumentExecutor has priority 11 (AutoUpdateRunSettings). Lower numbers execute first, so --testAdapterPath is always written to RunSettings before TryAddCodeCoverageAdapterPath runs its existingPaths check. The "skip if adapter path already set" invariant holds regardless of command-line argument order.
FindCollectorDirectory TFM tie-break — observation only. The OrderByDescending(StringComparer.Ordinal) puts netstandard2.0 ahead of net8.0 because 's' > '8' ordinally. If a future package ever ships the collector under both TFMs, the wrong one would win. The comment acknowledges this ("Real packages hold exactly one such folder"), so it is a documented assumption, not a current bug.
CompareNuGetVersions stable-wins rule — intentional semantic, but worth documenting for callers. 17.0.0 stable beats 18.6.0-preview-1. That is the desired behaviour (described in the PR), but it is a deliberate deviation from SemVer ordering that could surprise future maintainers. The existing XML doc comment already calls this out, so no action needed.
Exception filter in TryGetCodeCoverageAdapterPath — complete. DirectoryNotFoundException is a subclass of IOException and is therefore covered. PathTooLongException (also IOException) is covered. The filter is correct.
PR description alignment
Title and description accurately describe the change. The "does nothing when --testAdapterPath is already set" claim is verified correct (priority ordering above). The NuGet.Config globalPackagesFolder limitation is clearly documented.
Summary
No blocking issues found. The implementation is correct, deterministic, best-effort, and has comprehensive unit test coverage. The previous version-selection finding was fully addressed.
🧠 Reviewed by Copilot Expert Reviewer
🧠 Reviewed by Expert Code Reviewer 🧠
Fixes #15351
dotnet test *.dll --collect:"Code Coverage"fails withUnable to find a datacollector with friendly name 'Code Coverage'. In DLL mode the MSBuild task never runs, so the adapter path thatMicrosoft.CodeCoverage.propsnormally injects as--testAdapterPathis missing, and vstest.console cannot locate the collector. A.csprojrun works because the MSBuild task adds the folder that props file sits in.CollectArgumentExecutor.Initializenow does the same for--collect:"Code Coverage". It findsmicrosoft.codecoveragein the NuGet global packages folder (NUGET_PACKAGES, else~/.nuget/packages), and appends the folder holdingMicrosoft.VisualStudio.TraceDataCollector.dlltoTestAdaptersPaths. That is the same leaf folder the MSBuild path uses, so the run does not depend on the adapter loading strategy searching directories recursively. The folder is looked up rather than hardcoded, older packages ship the collector undernetstandard1.0instead ofnetstandard2.0.There is no project to ask which version to use, so the highest installed one wins, preferring a stable release over a pre-release so that a preview left in the package cache does not take over a run that never referenced it.
It does nothing when
--testAdapterPathis already set, when the package is not installed, or when the folder cannot be read, so a run that already works is untouched. Scoped to--collect,--enable-code-coveragestill relies on the MSBuild setup. SetVSTEST_DISABLE_CODE_COVERAGE_ADAPTER_DISCOVERY=1to turn the discovery off without having to pass an adapter path.NuGet.ConfigglobalPackagesFolderis not consulted, reading it means taking a dependency on the NuGet libraries.Tests in
CollectArgumentProcessorTestscover version selection, the collector folder lookup, injection, and the skip cases.🤖