[fix] Fix backslash normalization in VSTestCLIRunSettings on Unix - #15795
Conversation
When VSTestCLIRunSettings was declared as string[] in MSBuild tasks, MSBuild would wrap each value in an ITaskItem whose ItemSpec normalizes path separators on Unix — converting backslashes to forward slashes. This silently corrupted run settings that contained backslash characters, such as regex patterns passed via 'dotnet test -- NUnit.Where=...' . Fix: change VSTestCLIRunSettings from string[] to string in ITestTask, VSTestTask, and VSTestTask2. The string value is then split by newlines and semicolons within TestTaskUtils.CreateCommandLineArguments, avoiding ITaskItem creation and the associated path normalization. Multiple settings remain supported: they can be separated by semicolons (backward-compatible with MSBuild's default item separator) or newlines. Fixes #15043 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes Unix-specific corruption of backslashes in VSTestCLIRunSettings by avoiding MSBuild ITaskItem path normalization for that parameter, ensuring CLI run settings (e.g., regex patterns) are passed through unchanged.
Changes:
- Changed
VSTestCLIRunSettingsonVSTestTask/VSTestTask2(andITestTask) fromstring[]tostringto avoid MSBuild item normalization. - Updated
TestTaskUtils.CreateCommandLineArgumentsto manually split the run settings string by newline/semicolon and append them after--. - Updated/added unit tests and adjusted tracked public API entries.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/Microsoft.TestPlatform.Build.UnitTests/TestTaskUtilsTests.cs | Updates tests to use the new string runsettings format and adds a backslash-preservation test. |
| src/Microsoft.TestPlatform.Build/Tasks/VSTestTask2.cs | Changes VSTestCLIRunSettings type to string? on the ToolTask-based MSBuild task. |
| src/Microsoft.TestPlatform.Build/Tasks/VSTestTask.cs | Changes VSTestCLIRunSettings type to string? on the Task-based MSBuild task. |
| src/Microsoft.TestPlatform.Build/Tasks/TestTaskUtils.cs | Implements manual splitting/handling of CLI run settings while keeping -- as the final argument group. |
| src/Microsoft.TestPlatform.Build/Tasks/ITestTask.cs | Updates the internal task interface to the new string? type. |
| src/Microsoft.TestPlatform.Build/PublicAPI/PublicAPI.Unshipped.txt | Updates the tracked public API surface to reflect the property type change. |
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
🧠 Expert Review — [fix] Fix backslash normalization in VSTestCLIRunSettings on Unix
Summary
The fix is correct and well-reasoned. The root cause analysis is accurate: MSBuild wraps each element of a string[] task parameter in an ITaskItem, and on Unix, ITaskItem.ItemSpec normalizes path separators, silently converting \\ to /. Changing the property to string bypasses ITaskItem creation entirely.
Dimensions Checked
Public API Surface Protection ✅
The string[]? → string? signature change for VSTestCLIRunSettings is correctly reflected in PublicAPI.Unshipped.txt for both VSTestTask and VSTestTask2. Since this property was never in PublicAPI.Shipped.txt, there is no binary-compat break with previously released assemblies.
Backward Compatibility & Rollback Safety ✅
The .targets file in this repo invokes the task with VSTestCLIRunSettings="$(VSTestCLIRunSettings)" (a scalar MSBuild property string), not @(ItemGroup) syntax. Changing the task parameter type from string[] to string does not break this usage. MSBuild will coerce @(ItemGroup) to a semicolon-separated string when assigned to a string parameter anyway, and the new splitting logic handles that.
Null Safety & Boundary Validation ✅
Split(['\n', ';'], StringSplitOptions.RemoveEmptyEntries)eliminates empty slots from consecutive delimiters.arg.Trim()handles\rin Windows-style (\r\n) line endings.- The extra
IsNullOrEmpty(trimmed)guard is redundant afterRemoveEmptyEntriesbut defensively catches whitespace-only entries after trimming. Harmless.
Acceptance Test Coverage Design ✅
A new unit test CreateArgumentShouldPreserveBackslashesInCLIRunSettings directly validates the fix. The test uses a verbatim string @"NUnit.Where=namespace =~ /Abc\\.Space1($|\\.)/"and asserts that the literal Abc\\.Space1 (two backslashes) survives round-tripping through CreateCommandLineArguments.
Minor Observation (Non-Blocking)
Semicolon in setting values: Splitting by ; could misfire if a setting value itself contains a semicolon (e.g., SomeSetting=A;B). This is pre-existing behavior — the prior string[] design had the exact same limitation, since MSBuild also splits by ; when building ITaskItem[] for array parameters. This PR does not regress the situation, and the comment in the code explains the delimiter contract clearly.
Description Alignment ✅
The PR description accurately reflects the root cause (ITaskItem path normalization on Unix), the fix (change type to string, split manually), and the tests added. No gaps between description and diff.
🧠 Reviewed by Expert Code Reviewer
🧠 Reviewed by Expert Code Reviewer 🧠
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Adds BackslashParameterTestProject and an acceptance test verifying that backslashes in TestRunParameters survive through the MSBuild task without being normalized to forward slashes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
🧠 Expert Review — [fix] Fix backslash normalization in VSTestCLIRunSettings on Unix
Summary
The fix is correct and well-scoped. Changing VSTestCLIRunSettings from string[] to string correctly bypasses MSBuild's ITaskItem path normalization. The PublicAPI.Unshipped.txt updates, the split/trim logic, and the unit tests all look sound.
Dimensions Checked
Public API Surface Protection ✅
PublicAPI.Unshipped.txt is updated for both VSTestTask and VSTestTask2. The property was never in PublicAPI.Shipped.txt, so there is no binary-compat break with released assemblies.
Backward Compatibility & Rollback Safety ✅
The .targets file passes VSTestCLIRunSettings as a scalar MSBuild property string. MSBuild coerces @(ItemGroup) to a semicolon-separated string when assigned to a string parameter, and the new split logic handles ; explicitly — preserving backward compatibility.
Null Safety & Boundary Validation ✅
Split(['\\n', ';'], RemoveEmptyEntries) + Trim() handles \r\n line endings and consecutive delimiters. The extra IsNullOrEmpty guard after RemoveEmptyEntries is redundant but harmless.
Acceptance Test Coverage Design
The integration test is gated to Windows, but the bug only manifests on Unix. The unit test is the primary regression guard here; the integration test provides no coverage for the actual failure scenario.
Description Alignment ✅
The PR description accurately reflects the root cause, fix, and tests added. No gaps.
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
Explain that the test is Windows-only due to patched dotnet not being published on non-Windows, and that the unit test CreateArgumentShouldPreserveBackslashesInCLIRunSettings is the primary regression guard for the Unix-specific bug path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Commit pushed:
|
|
needs latest main merged where we run the dotnet test tests on unix as well, and re-test, as pointed out by expert reviewer. |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Remove the [TestCategory("Windows-Review")] restriction and the
now-stale Windows-only comment from RunDotnetTestWithCLIRunSettingsContainingBackslashes.
Patched dotnet is now published cross-platform (see #16067), so the
test can run on Linux/macOS CI as well.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Commit pushed:
|
This comment has been minimized.
This comment has been minimized.
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
🧠 Expert Review — [fix] Fix backslash normalization in VSTestCLIRunSettings on Unix
Summary
This iteration addresses the outstanding finding from the prior review: the integration test RunDotnetTestWithCLIRunSettingsContainingBackslashes no longer carries [TestCategory("Windows-Review")] and will now run on Linux/macOS CI — directly exercising the failure scenario the fix addresses.
All other dimensions remain sound from prior analysis.
Dimensions Checked
Public API Surface Protection ✅
PublicAPI.Unshipped.txt correctly updated for both VSTestTask and VSTestTask2. Neither property was ever in PublicAPI.Shipped.txt, so there is no binary-compat break.
Backward Compatibility & Rollback Safety ✅
The .targets file passes VSTestCLIRunSettings as a scalar MSBuild property string. MSBuild coerces @(ItemGroup) to a semicolon-separated string when assigning to a string parameter, and the split-by-; logic handles that path explicitly.
Null Safety & Boundary Validation ✅
Split(['\n', ';'], RemoveEmptyEntries) + Trim() correctly handles \r\n Windows line endings and consecutive delimiters. The extra IsNullOrEmpty(trimmed) guard after RemoveEmptyEntries is redundant but harmless.
Acceptance Test Coverage Design ✅ (prior finding resolved)
The integration test now runs on all CI platforms. The BackslashTests.cs assertion logic is correct: input Namespace\.Class\b contains backslashes and no forward slashes; if ITaskItem normalization regresses, the value becomes Namespace/.Class/b (contains /, loses \), and both assertions catch it.
Source Build & Cross-Platform Compliance ✅
The fix is purely at the MSBuild task parameter type level — no platform-specific #if guards needed. The split logic is portable.
Description Alignment ✅
PR description accurately reflects root cause, fix, and test coverage. No gaps between description and diff.
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
The test uses patched dotnet which is only published on Windows. The primary regression guard for the Unix backslash normalization fix is the unit test CreateArgumentShouldPreserveBackslashesInCLIRunSettings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Commit pushed:
|
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
🧠 Expert Review — [fix] Fix backslash normalization in VSTestCLIRunSettings on Unix
Summary
Follow-up review of HEAD 253b9aba — a merge-from-main commit that picks up PR #16067 (removal of unrelated [TestCategory("Windows-Review")] gates in DotnetTestTests.cs). No PR-specific code was changed; the review confirms the prior clean verdict holds.
Dimensions Checked
RunSettings Validation & Inference ✅
TestTaskUtils.CreateCommandLineArguments now splits the string value by \n and ; with RemoveEmptyEntries, then Trim()s each token. \r in Windows-style \r\n line endings is stripped by Trim(). The extra IsNullOrEmpty(trimmed) guard after RemoveEmptyEntries is redundant but harmless. No regressions.
Public API Surface Protection ✅
PublicAPI.Unshipped.txt correctly reflects string? for VSTestCLIRunSettings on both VSTestTask and VSTestTask2. Neither was in PublicAPI.Shipped.txt — no binary-compat break.
Backward Compatibility & Rollback Safety ✅
MSBuild coerces @(ItemGroup) to a semicolon-separated string when assigning to a string task parameter, and the split-by-; logic handles that path explicitly. Existing .targets usages of $(VSTestCLIRunSettings) (scalar property) are unaffected.
Acceptance Test Coverage Design ✅
RunDotnetTestWithCLIRunSettingsContainingBackslashes has no [TestCategory("Windows-Review")] gate at this HEAD — confirmed from the full file read. The test runs on all CI platforms via [NetCoreTargetFrameworkDataSource(useDesktopRunner: false)]. The BackslashTests.ParameterContainsBackslashes assertion pair (!Contains("/") + Contains("\\")) is a sound regression guard: if ITaskItem normalization regresses, Namespace\.Class\b → Namespace/.Class/b fails both assertions.
Null Safety & Boundary Validation ✅
No change to null handling since prior review. All edge cases remain covered.
Description Alignment ✅
PR description accurately describes root cause, fix, and test coverage. No gaps between description and diff at current HEAD.
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
This comment has been minimized.
This comment has been minimized.
|
Warning The Commit pushed: updated
|
…to main Blank or separator-only VSTestCLIRunSettings appended a lone -- to the vstest.console command line. Split the value first and emit the separator only when at least one setting survives. Adds unit tests for semicolon separated and CRLF separated input and for the blank cases, aligns the backslash acceptance test with the neighbouring inline settings test so a failure reports the run summary, and moves the new test asset to its alphabetical place in TestAssets.slnx. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
# Conflicts: # test/Microsoft.TestPlatform.Acceptance.IntegrationTests/DotnetTestTests.cs
RunDotnetTestWithCLIRunSettingsContainingBackslashes kept failing on Linux and macOS with 'Namespace//.Class//b'. That doubled slash is what ITaskItem.ItemSpec produces on Unix, so the inner dotnet test was still running the old string[] binding even though this branch declares VSTestCLIRunSettings as string. I checked the binding on Linux with a small MSBuild project, a string parameter keeps the value intact and a string[] parameter turns it into exactly the value that CI reported. Pass /p:VSTestTaskAssemblyFile so the run loads the task from the package we just built instead of whatever Microsoft.TestPlatform.Build.dll the SDK happens to ship, and check the file is there first, so a missing path fails with a clear message instead of quietly falling back. Add a unit test that asserts VSTestCLIRunSettings stays a string on both VSTestTask and VSTestTask2. That is the regression worth guarding against and it runs on every platform. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> 🤖
There was a problem hiding this comment.
🧠 Expert Review — [fix] Fix backslash normalization in VSTestCLIRunSettings on Unix
Summary
The root cause analysis and fix are correct. Changing VSTestCLIRunSettings from string[] to string is the right lever: ITaskItem.ItemSpec is exactly where the path normalization happens, and bypassing it by using a scalar string is a clean solution. The SplitCLIRunSettings helper correctly handles all three separator forms (\n, \r\n, ;) and the test coverage is comprehensive.
Dimensions Checked
| Dimension | Status |
|---|---|
| Public API Surface Protection | ✅ PublicAPI.Unshipped.txt updated for both tasks and the interface |
| Backward Compatibility & Rollback Safety | |
| Acceptance Test Coverage Design | ✅ Integration test uses VSTestTaskAssemblyFile to pin the locally built task — good, this is the right way to avoid silent fallback to the SDK-shipped DLL |
| Null Safety & Boundary Validation | ✅ Null/whitespace/empty inputs all handled and tested |
| Build Script & Infrastructure Hygiene | ✅ New test asset added to TestAssets.slnx |
Findings
One low-severity backward compatibility note (see inline comment on SplitCLIRunSettings): the %3B MSBuild escape sequence could previously be used to embed a literal ; in a run-setting value when using the string[] form. That escape path is now closed because MSBuild decodes %3B before the task sees the scalar string, and SplitCLIRunSettings then splits on the decoded ;. In practice, ; in run-setting values is extremely rare, and the old behavior was also broken for direct ; usage (MSBuild item splitting), so the real-world impact should be negligible. A doc comment note would help future maintainers.
Description Alignment
PR title and description accurately match the diff. The explanation of why CI was still red (task falling back to the SDK-shipped DLL) is well-documented and the fix (pinning via VSTestTaskAssemblyFile) is verified by the infrastructure check (Assert.IsTrue(File.Exists(buildTaskPath), ...)).
🧠 Reviewed by Expert Code Reviewer
🧠 Reviewed by Expert Code Reviewer 🧠
MSBuild unescapes the property before it reaches the scalar string parameter, so %3B arrives as a plain semicolon and SplitCLIRunSettings splits on it. The old string[] parameter kept that as one entry, so the escape route is gone. That is deliberate, the array form rewrote backslashes to forward slashes on Unix and broke every setting that held a regex. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> 🤖
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Microsoft.TestPlatform.Build/Tasks/TestTaskUtils.cs:392
SplitCLIRunSettingscurrently splits on every semicolon (value.Split(['\r','\n',';'])). This breaks valid CLI run settings where the value itself contains semicolons (e.g. connection strings inTestRunParameters.Parameter(... value="Server=...;Database=..." )). The repo docs explicitly describe this scenario as supported (docs/RunSettingsArguments.mdaround the “Handling semicolons…” section), but the current implementation will split the value into multiple arguments.
Consider parsing separators so that ; only splits between entries (i.e., not inside quoted text), while still supporting newline-separated entries.
foreach (var entry in value.Split(['\r', '\n', ';'], StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = entry.Trim();
if (!StringUtils.IsNullOrEmpty(trimmed))
{
test/Microsoft.TestPlatform.Build.UnitTests/TestTaskUtilsTests.cs:109
- There’s currently coverage for semicolon as an entry separator (
arg1;arg2), but not for semicolons that appear inside a quoted CLI run setting value (e.g. connection strings inTestRunParameters.Parameter(... value="Server=...;Database=..." )). Since semicolons in values are documented as supported, adding a regression test here would help prevent future parsing changes from corrupting those values.
[TestMethod]
public void CreateArgumentShouldSplitCLIRunSettingsOnSemicolon()
{
// dotnet test joins the arguments that follow "--" with a semicolon before it sets the
// VSTestCLIRunSettings property, so semicolon separated input has to keep working.
There was a problem hiding this comment.
🧠 Expert Review — [fix] Fix backslash normalization in VSTestCLIRunSettings on Unix
Summary
The fix is correct and complete. The root cause (MSBuild binding string[] parameters as ITaskItem[], which normalizes paths on Unix) is well-understood and the remedy — switching to a string scalar parameter — is the right level to solve it. The .targets file already passes $(VSTestCLIRunSettings) as a property, not an item group, so no .targets changes are needed and the binding path is clean.
Dimensions checked: Public API Surface Protection · Backward Compatibility & Rollback Safety · Null Safety & Boundary Validation · Acceptance Test Coverage Design
[Backward Compatibility] %3B-escaped semicolons silently change behavior
The XML doc comment on SplitCLIRunSettings documents this trade-off accurately:
MSBuild unescapes the property before it reaches this scalar string parameter, so
%3Barrives as a plain semicolon and is split here, while the formerstring[]parameter kept it as one entry.
With string[], MSBuild first split the property on ; into items, then unescaped %3B → ; within each item's ItemSpec, so foo%3Bbar became a single item holding a literal semicolon. With string, MSBuild unescapes before the call, so foo%3Bbar reaches SplitCLIRunSettings as foo;bar and is split into two entries.
This is an intentional trade-off and the right call — the backslash bug affected far more users. Just making sure it's flagged explicitly: any caller that relied on %3B to embed a literal semicolon in a single run setting will now silently get two malformed settings instead. The remarks are accurate, but a runtime [Obsolete]-style MSBuild warning is not possible here, so users won't discover this until their test run breaks. Severity: LOW — obscure usage that was never officially documented, but worth tracking in release notes.
[Null Safety] Trim() is a new behavior on each split entry
SplitCLIRunSettings now calls entry.Trim() before adding each item:
var trimmed = entry.Trim();
if (!StringUtils.IsNullOrEmpty(trimmed))
settings.Add(trimmed);The old string[] path iterated ITaskItem.ItemSpec values directly without trimming. The new code strips any leading/trailing whitespace from each entry. For the $(VSTestCLIRunSettings) MSBuild property path this is harmless and probably an improvement, but it is a subtle behavioral delta. Severity: negligible — run settings are Key=Value pairs and no known format uses significant leading/trailing whitespace at the entry level.
[Acceptance Test] Path hardcodes netstandard2.0
var buildTaskPath = Path.Combine(
IntegrationTestEnvironment.PublishDirectory,
$"Microsoft.TestPlatform.Build.{...}.nupkg",
"lib",
"netstandard2.0", // ← hardcoded TFM
"Microsoft.TestPlatform.Build.dll");Microsoft.TestPlatform.Build currently ships only netstandard2.0, so the path is correct today. If the package ever gains a net8.0 or later TFM lib, the test's Assert.IsTrue(File.Exists(...)) will start failing with a misleading path error rather than a test logic failure. The assertion's error message includes the full path, which makes debugging easy enough; this is worth noting but not a blocker. Severity: LOW.
Positive findings
- API surface correctly updated.
PublicAPI.Unshipped.txtis updated in bothVSTestTaskandVSTestTask2entries. The type change fromstring![]?tostring?is complete and consistent. - Test coverage is thorough. The new unit tests cover: semicolon splitting, CRLF splitting, empty/whitespace-only inputs (regression for the lone-
--bug), and a reflection-based assertion that the property type staysstring— a smart guard against future accidental reversion. ITestTaskisinternal, so the API change does not affect binary compatibility of callers outside the assembly. The only impact is MSBuild task reflection binding, which is exactly what the fix targets.- Regression test loads the locally built DLL via
/p:VSTestTaskAssemblyFile, which is the correct pattern to avoid silently testing the SDK's shipped copy.
🧠 Reviewed by Expert Code Review 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
Fixes #15043
Root Cause
VSTestCLIRunSettingswas declared asstring[]inITestTask,VSTestTask, andVSTestTask2. When MSBuild processes astring[]task parameter, it wraps each element in anITaskItem. On Unix,ITaskItem.ItemSpecnormalizes path separators, silently converting\to/.This corrupted any run setting containing backslash characters — such as regex patterns passed via:
The
\.sequences became//, causing the regex to fail.Fix
Changed
VSTestCLIRunSettingsfromstring[]tostringinITestTask,VSTestTask, andVSTestTask2. This bypassesITaskItemcreation entirely, so no path normalization occurs. InTestTaskUtils.CreateCommandLineArguments, the string is now split manually by newlines and semicolons (maintaining backward compatibility with both separators).Tests
CreateArgumentShouldAddOneEntryForCLIRunSettingsandCreateArgumentShouldAddCLIRunSettingsArgAtEndtests to use the newstringtype with newline separator.CreateArgumentShouldPreserveBackslashesInCLIRunSettingsthat verifies backslashes in CLI run settings are not converted to forward slashes.VSTestCLIRunSettingsMustBindAsStringToSurviveUnixPathNormalization, which asserts the parameter stays astringon both tasks. That is the regression worth guarding against and it runs on every platform.Why CI was red
RunDotnetTestWithCLIRunSettingsContainingBackslashesfailed on Linux and macOS withNamespace//.Class//b. That doubled slash is whatITaskItem.ItemSpecproduces, so the innerdotnet testwas still running the oldstring[]binding even though this branch declares the property asstring. I checked the binding on Linux with a small MSBuild project that takes the same value as both astringand astring[]parameter:stringTestRunParameters.Parameter(name=\"pattern\",;value=\"Namespace\\.Class\\b\")string[]TestRunParameters.Parameter(name=/"pattern/", value=/"Namespace//.Class//b/")The
string[]row is exactly what CI reported, so the fix itself is correct and the test was simply not loading the task we build. It now passes/p:VSTestTaskAssemblyFileto point the VSTest targets at the task from our own package instead of whateverMicrosoft.TestPlatform.Build.dllthe SDK ships, and it checks that file exists first so a wrong path fails with a clear message instead of quietly falling back.🤖