Skip to content
Draft
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
73 changes: 73 additions & 0 deletions src/vstest.console/InProcessVsTestConsoleWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

Expand Down Expand Up @@ -116,6 +117,18 @@ internal InProcessVsTestConsoleWrapper(
{
environmentVariableBaseline[entry?.Key.ToString()!] = entry?.Value?.ToString();
}

// On Windows, Environment.GetEnvironmentVariables() omits entries whose keys start
// with '=' (e.g. "=C:=C:\path" — drive-relative current directory entries kept by
// the native environment block). Supplement the managed snapshot with those entries
// so that testhost receives a complete environment.
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
foreach (var (key, value) in GetWindowsNativeEqualsEnvironmentVariables())
{
environmentVariableBaseline[key] = value;
}
}
Comment on lines +121 to +131
}

foreach (var pair in consoleParameters.EnvironmentVariables)
Expand Down Expand Up @@ -1301,4 +1314,64 @@ private bool WaitForConnection()
_testPlatformEventSource.TranslationLayerInitializeStop();
return true;
}

/// <summary>
/// On Windows, reads the raw native environment block via P/Invoke to collect entries
/// whose keys start with '=' (drive-relative current directory entries). These are silently
/// omitted by <see cref="Environment.GetEnvironmentVariables()"/> but are present in the
/// native environment block. They must be forwarded to the testhost so that drive-relative
/// paths resolve correctly.
/// </summary>
private static IEnumerable<(string Key, string? Value)> GetWindowsNativeEqualsEnvironmentVariables()
{
IntPtr block = GetEnvironmentStringsW();
if (block == IntPtr.Zero)
{
yield break;
}
Comment on lines +1325 to +1331

try
{
int offset = 0;
while (true)
{
string entry = Marshal.PtrToStringUni(IntPtr.Add(block, offset * 2))!;
if (entry.Length == 0)
{
break;
}

// Only yield entries whose key starts with '=' — these are the ones that the
// managed API drops. All other entries are already included via GetEnvironmentVariables().
if (entry.StartsWith("=", StringComparison.Ordinal))
{
int separatorIndex = entry.IndexOf('=', 1);
if (separatorIndex > 0)
{
string key = entry.Substring(0, separatorIndex);
string value = entry.Substring(separatorIndex + 1);
yield return (key, value);
}
else
{
// Key with no value (just "=key", no second '=').
yield return (entry, null);
}
}

offset += entry.Length + 1;
}
}
finally
{
FreeEnvironmentStringsW(block);
}
}

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr GetEnvironmentStringsW();

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool FreeEnvironmentStringsW(IntPtr lpszEnvironmentBlock);
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,13 @@ public void InProcessWrapperConstructorShouldSetEnvironmentVariablesReceivedAsCo
new Mock<ITestPlatformEventSource>().Object,
new());

Assert.AreEqual(3, ProcessHelper.ExternalEnvironmentVariables?.Count);
// On Windows, GetWindowsNativeEqualsEnvironmentVariables() may add '='-prefixed
// drive-relative current-directory entries (e.g. "=C:=C:\...") from the native
// environment block. Exclude those when verifying the expected managed-API count.
int nonNativeCount = ProcessHelper.ExternalEnvironmentVariables?.Keys
.Cast<string>()
.Count(k => !k.StartsWith("=", StringComparison.Ordinal)) ?? 0;
Assert.AreEqual(3, nonNativeCount);
Assert.IsTrue(ProcessHelper.ExternalEnvironmentVariables?.ContainsKey(environmentVariableName1));
Assert.AreEqual("1", ProcessHelper.ExternalEnvironmentVariables?[environmentVariableName1]);
Assert.IsTrue(ProcessHelper.ExternalEnvironmentVariables?.ContainsKey(environmentVariableName2));
Expand All @@ -156,6 +162,38 @@ public void InProcessWrapperConstructorShouldSetEnvironmentVariablesReceivedAsCo
Assert.AreEqual("1", ProcessHelper.ExternalEnvironmentVariables?[environmentVariableName3]);
}

[TestMethod]
[TestCategory("Windows")]
[OSCondition(OperatingSystems.Windows)]
public void InProcessWrapperConstructorWithRealEnvironmentHelper_OnWindows_IncludesNativeEqualsEnvironmentVariablesInProcessHelper()
{
var consoleParams = new ConsoleParameters { InheritEnvironmentVariables = true };

// Use a real IEnvironmentVariableHelper so that GetEnvironmentVariables() returns the
// actual managed snapshot (which omits '='-prefixed keys on Windows). The fix in
// InProcessVsTestConsoleWrapper must supplement that snapshot via P/Invoke so that
// testhost receives the full native environment block.
_ = new InProcessVsTestConsoleWrapper(
consoleParams,
new RealEnvironmentVariableHelper(),
_mockRequestSender.Object,
_mockTestRequestManager.Object,
new Executor(_mockOutput.Object, new Mock<ITestPlatformEventSource>().Object, new ProcessHelper(), new PlatformEnvironment()),
new Mock<ITestPlatformEventSource>().Object,
new());

// Windows always maintains at least one '=<Drive>:' entry in the native env block (e.g.
// "=C:=C:\..."). Without the fix these entries are invisible to the managed API and would
// not be forwarded to testhost.
var hasEqualsKey = ProcessHelper.ExternalEnvironmentVariables?.Keys
.Cast<string>()
.Any(k => k.StartsWith("=", StringComparison.Ordinal));

Assert.IsTrue(hasEqualsKey,
"ProcessHelper.ExternalEnvironmentVariables must contain at least one '='-prefixed " +
"drive-relative current-directory entry on Windows when InheritEnvironmentVariables is true.");
}

[TestMethod]
public void InProcessWrapperConstructorShouldSetTheCultureSpecifiedByTheUser()
{
Expand Down Expand Up @@ -1111,4 +1149,32 @@ await _consoleWrapper.ProcessTestRunAttachmentsAsync(
It.IsAny<ITestRunAttachmentsProcessingEventsHandler>(),
It.IsAny<ProtocolConfig>()), Times.Once);
}

/// <summary>
/// A non-mocked <see cref="IEnvironmentVariableHelper"/> that delegates to the real
/// <see cref="Environment"/> APIs. Used to exercise the native Windows P/Invoke code path
/// inside <see cref="InProcessVsTestConsoleWrapper"/> that supplements the managed env snapshot
/// with <c>=</c>-prefixed drive-relative current-directory entries.
/// </summary>
private sealed class RealEnvironmentVariableHelper : IEnvironmentVariableHelper
{
public IDictionary GetEnvironmentVariables() => Environment.GetEnvironmentVariables();

public string? GetEnvironmentVariable(string variable) => Environment.GetEnvironmentVariable(variable);

public TEnum GetEnvironmentVariableAsEnum<TEnum>(string variable, TEnum defaultValue = default)
where TEnum : struct, Enum
{
var value = Environment.GetEnvironmentVariable(variable);
if (string.IsNullOrEmpty(value))
{
return defaultValue;
}

return Enum.TryParse<TEnum>(value, out var result) ? result : defaultValue;
}

public void SetEnvironmentVariable(string variable, string value) =>
Environment.SetEnvironmentVariable(variable, value);
}
}