|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Resources;
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
using Microsoft.Build.UnitTests;
using Microsoft.Build.Utilities;
using Shouldly;
using Xunit;
#nullable disable
namespace Microsoft.Build.UnitTests
{
public sealed class ToolTask_Tests
{
private readonly ITestOutputHelper _output;
public ToolTask_Tests(ITestOutputHelper testOutput)
{
_output = testOutput;
}
private class MyTool : ToolTask, IDisposable
{
private string _fullToolName;
private string _responseFileCommands = string.Empty;
private string _commandLineCommands = string.Empty;
private string _pathToToolUsed;
public MyTool(ResourceManager resourceManager = null)
: base()
{
base.TaskResources = resourceManager;
_fullToolName = Path.Combine(
NativeMethodsShared.IsUnixLike ? "/bin" : Environment.GetFolderPath(Environment.SpecialFolder.System),
NativeMethodsShared.IsUnixLike ? "sh" : "cmd.exe");
}
public void Dispose()
{
}
public string PathToToolUsed => _pathToToolUsed;
public string MockResponseFileCommands
{
set => _responseFileCommands = value;
}
public string MockCommandLineCommands
{
set => _commandLineCommands = value;
}
public string FullToolName
{
set => _fullToolName = value;
}
/// <summary>
/// Intercepted start info
/// </summary>
internal ProcessStartInfo StartInfo { get; private set; }
/// <summary>
/// Whether execute was called
/// </summary>
internal bool ExecuteCalled { get; private set; }
internal Action<ProcessStartInfo> DoProcessStartInfoMutation { get; set; }
protected override string ToolName => Path.GetFileName(_fullToolName);
protected override string GenerateFullPathToTool() => _fullToolName;
protected override string GenerateResponseFileCommands() => _responseFileCommands;
protected override string GenerateCommandLineCommands() => _commandLineCommands;
protected override ProcessStartInfo GetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch)
{
var basePSI = base.GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch);
DoProcessStartInfoMutation?.Invoke(basePSI);
return basePSI;
}
protected override void LogEventsFromTextOutput(string singleLine, MessageImportance messageImportance)
{
if (singleLine.Contains("BADTHINGHAPPENED"))
{
// This is where a customer's tool task implementation could do its own
// parsing of the errors in the stdout/stderr output of the tool being wrapped.
Log.LogError(singleLine);
}
else
{
base.LogEventsFromTextOutput(singleLine, messageImportance);
}
}
protected override int ExecuteTool(string pathToTool, string responseFileCommands, string commandLineCommands)
{
Console.WriteLine("executetool");
_pathToToolUsed = pathToTool;
ExecuteCalled = true;
if (!NativeMethodsShared.IsWindows && string.IsNullOrEmpty(responseFileCommands) && string.IsNullOrEmpty(commandLineCommands))
{
// Unix makes sh interactive and it won't exit if there is nothing on the command line
commandLineCommands = " -c \"echo\"";
}
int result = base.ExecuteTool(pathToTool, responseFileCommands, commandLineCommands);
StartInfo = GetProcessStartInfo(GenerateFullPathToTool(), NativeMethodsShared.IsWindows ? "/x" : string.Empty, null);
return result;
}
}
[Fact]
public void Regress_Mutation_UserSuppliedToolPathIsLogged()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.ToolPath = NativeMethodsShared.IsWindows ? @"C:\MyAlternatePath" : "/MyAlternatePath";
t.Execute();
// The alternate path should be mentioned in the log.
engine.AssertLogContains("MyAlternatePath");
}
}
[Fact]
public void Regress_Mutation_MissingExecutableIsLogged()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.ToolPath = NativeMethodsShared.IsWindows ? @"C:\MyAlternatePath" : "/MyAlternatePath";
t.Execute().ShouldBeFalse();
// There should be an error about invalid task location.
engine.AssertLogContains("MSB6004");
}
}
[Fact]
public void Regress_Mutation_WarnIfCommandLineTooLong()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
// "cmd.exe" croaks big-time when given a very long command-line. It pops up a message box on
// Windows XP. We can't have that! So use "attrib.exe" for this exercise instead.
string systemPath = NativeMethodsShared.IsUnixLike ? "/bin" : Environment.GetFolderPath(Environment.SpecialFolder.System);
t.FullToolName = Path.Combine(systemPath, NativeMethodsShared.IsWindows ? "attrib.exe" : "ps");
t.MockCommandLineCommands = new string('x', 32001);
// It's only a warning, we still succeed
t.Execute().ShouldBeTrue();
t.ExitCode.ShouldBe(0);
// There should be a warning about the command-line being too long.
engine.AssertLogContains("MSB6002");
}
}
/// <summary>
/// Exercise the code in ToolTask's default implementation of HandleExecutionErrors.
/// </summary>
[Fact]
public void HandleExecutionErrorsWhenToolDoesntLogError()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.MockCommandLineCommands = NativeMethodsShared.IsWindows ? "/C garbagegarbagegarbagegarbage.exe" : "-c garbagegarbagegarbagegarbage.exe";
t.Execute().ShouldBeFalse();
t.ExitCode.ShouldBe(NativeMethodsShared.IsWindows ? 1 : 127); // cmd.exe error code is 1, sh error code is 127
// We just tried to run "cmd.exe /C garbagegarbagegarbagegarbage.exe". This should fail,
// but since "cmd.exe" doesn't log its errors in canonical format, no errors got
// logged by the tool itself. Therefore, ToolTask's default implementation of
// HandleTaskExecutionErrors should have logged error MSB6006.
engine.AssertLogContains("MSB6006");
}
}
/// <summary>
/// Exercise the code in ToolTask's default implementation of HandleExecutionErrors.
/// </summary>
[Fact]
[Trait("Category", "netcore-osx-failing")]
[Trait("Category", "netcore-linux-failing")]
public void HandleExecutionErrorsWhenToolLogsError()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/C echo Main.cs(17,20): error CS0168: The variable 'foo' is declared but never used"
: @"-c """"""echo Main.cs\(17,20\): error CS0168: The variable 'foo' is declared but never used""""""";
t.Execute().ShouldBeFalse();
// The above command logged a canonical error message. Therefore ToolTask should
// not log its own error beyond that.
engine.AssertLogDoesntContain("MSB6006");
engine.AssertLogContains("CS0168");
engine.AssertLogContains("The variable 'foo' is declared but never used");
t.ExitCode.ShouldBe(-1);
engine.Errors.ShouldBe(1);
}
}
/// <summary>
/// ToolTask should never run String.Format on strings that are
/// not meant to be formatted.
/// </summary>
[Fact]
public void DoNotFormatTaskCommandOrMessage()
{
using MyTool t = new MyTool();
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
// Unmatched curly would crash if they did
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/C echo hello world {"
: @"-c ""echo hello world {""";
t.Execute();
engine.AssertLogContains("echo hello world {");
engine.Errors.ShouldBe(0);
}
/// <summary>
/// Process notification encoding should be consistent with console code page.
/// not meant to be formatted.
/// </summary>
[InlineData(0, "")]
[InlineData(-1, "1>&2")]
[Theory]
public void ProcessNotificationEncodingConsistentWithConsoleCodePage(int exitCode, string errorPart)
{
using MyTool t = new MyTool();
MockEngine engine = new MockEngine();
t.BuildEngine = engine;
t.UseCommandProcessor = true;
t.LogStandardErrorAsError = true;
t.EchoOff = true;
t.UseUtf8Encoding = EncodingUtilities.UseUtf8Always;
string content = "Building Custom Rule プロジェクト";
string outputMessage = exitCode == 0 ? content : $"'{content}' {errorPart}";
string commandLine = $"echo {outputMessage}";
t.MockCommandLineCommands = commandLine;
t.Execute();
t.ExitCode.ShouldBe(exitCode);
string log = engine.Log;
string singleQuote = NativeMethodsShared.IsWindows ? "'" : string.Empty;
string displayMessage = exitCode == 0 ? content : $"ERROR : {singleQuote}{content}{singleQuote}";
string pattern = $"{commandLine}{Environment.NewLine}\\s*{displayMessage}";
Regex regex = new Regex(pattern);
regex.Matches(log).Count.ShouldBe(1, $"{log} doesn't contain the log matching the pattern: {pattern}");
}
/// <summary>
/// When a message is logged to the standard error stream do not error is LogStandardErrorAsError is not true or set.
/// </summary>
[Fact]
public void DoNotErrorWhenTextSentToStandardError()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/C Echo 'Who made you king anyways' 1>&2"
: @"-c ""echo Who made you king anyways 1>&2""";
t.Execute().ShouldBeTrue();
engine.AssertLogDoesntContain("MSB");
engine.AssertLogContains("Who made you king anyways");
t.ExitCode.ShouldBe(0);
engine.Errors.ShouldBe(0);
}
}
/// <summary>
/// When a message is logged to the standard output stream do not error is LogStandardErrorAsError is true
/// </summary>
[Fact]
public void DoNotErrorWhenTextSentToStandardOutput()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.LogStandardErrorAsError = true;
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/C Echo 'Who made you king anyways'"
: @"-c ""echo Who made you king anyways""";
t.Execute().ShouldBeTrue();
engine.AssertLogDoesntContain("MSB");
engine.AssertLogContains("Who made you king anyways");
t.ExitCode.ShouldBe(0);
engine.Errors.ShouldBe(0);
}
}
/// <summary>
/// When LogStandardErrorAsError is true and text is sent to stderr, the tool exits with
/// code 0 but ToolTask overrides the exit code to -1. The low-importance message
/// "The command exited with return value 0, but errors were detected" should be logged,
/// not the generic tool failure error MSB6006.
/// </summary>
[Fact]
public void ErrorWhenTextSentToStandardError()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.LogStandardErrorAsError = true;
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/C Echo 'Who made you king anyways' 1>&2"
: @"-c ""echo 'Who made you king anyways' 1>&2""";
t.Execute().ShouldBeFalse();
engine.AssertLogContains("Who made you king anyways");
// Should not log other failure error codes
engine.AssertLogDoesntContain("MSB3073");
t.ExitCode.ShouldBe(-1);
// Only the stderr-as-error from tool output
engine.Errors.ShouldBe(1);
}
}
/// <summary>
/// When the tool exits with a non-zero exit code and has already logged its own errors,
/// ToolTask should log the "command exited with code" message (not MSB6006) as a low-importance
/// diagnostic rather than a duplicate error.
/// </summary>
[Fact]
public void HandleExecutionErrorsWhenToolLogsErrorAndExitsNonZero()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/C echo BADTHINGHAPPENED && exit /b 1"
: @"-c ""echo BADTHINGHAPPENED; exit 1""";
t.Execute().ShouldBeFalse();
engine.AssertLogContains("BADTHINGHAPPENED");
// Should not log the generic tool failure error or the zero-with-errors message
engine.AssertLogDoesntContain("MSB3073");
engine.AssertLogDoesntContain("exited with return value 0");
t.ExitCode.ShouldBe(1);
// Only the custom error logged by MyTool.LogEventsFromTextOutput
engine.Errors.ShouldBe(1);
}
}
/// <summary>
/// When ToolExe is set, it is used instead of ToolName
/// </summary>
[Fact]
public void ToolExeWinsOverToolName()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.FullToolName = NativeMethodsShared.IsWindows ? "c:\\baz\\foo.exe" : "/baz/foo.exe";
t.ToolExe.ShouldBe("foo.exe");
t.ToolExe = "bar.exe";
t.ToolExe.ShouldBe("bar.exe");
}
}
/// <summary>
/// When ToolExe is set, it is appended to ToolPath instead
/// of the regular tool name
/// </summary>
[Fact]
public void ToolExeIsFoundOnToolPath()
{
string shellName = NativeMethodsShared.IsWindows ? "cmd.exe" : "sh";
string copyName = NativeMethodsShared.IsWindows ? "xcopy.exe" : "cp";
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.FullToolName = shellName;
string systemPath = NativeMethodsShared.IsUnixLike ? "/bin" : Environment.GetFolderPath(Environment.SpecialFolder.System);
t.ToolPath = systemPath;
t.Execute();
t.PathToToolUsed.ShouldBe(Path.Combine(systemPath, shellName));
engine.AssertLogContains(shellName);
engine.Log = string.Empty;
t.ToolExe = copyName;
t.Execute();
t.PathToToolUsed.ShouldBe(Path.Combine(systemPath, copyName));
engine.AssertLogContains(copyName);
engine.AssertLogDoesntContain(shellName);
}
}
/// <summary>
/// Task is not found on path - regress #499196
/// </summary>
[Fact]
public void TaskNotFoundOnPath()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.FullToolName = "doesnotexist.exe";
t.Execute().ShouldBeFalse();
t.ExitCode.ShouldBe(-1);
engine.Errors.ShouldBe(1);
// Does not throw an exception
}
}
/// <summary>
/// Task is found on path.
/// </summary>
[Fact]
public void TaskFoundOnPath()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
string toolName = NativeMethodsShared.IsWindows ? "cmd.exe" : "sh";
t.FullToolName = toolName;
t.Execute().ShouldBeTrue();
t.ExitCode.ShouldBe(0);
engine.Errors.ShouldBe(0);
string systemPath = NativeMethodsShared.IsUnixLike ? "/bin" : Environment.GetFolderPath(Environment.SpecialFolder.System);
engine.AssertLogContains(
Path.Combine(systemPath, toolName));
}
}
/// <summary>
/// StandardOutputImportance set to Low should not show up in our log
/// </summary>
[Fact]
public void OverrideStdOutImportanceToLow()
{
string tempFile = FileUtilities.GetTemporaryFileName();
File.WriteAllText(tempFile, @"hello world");
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
engine.MinimumMessageImportance = MessageImportance.High;
t.BuildEngine = engine;
t.FullToolName = NativeMethodsShared.IsWindows ? "findstr.exe" : "grep";
t.MockCommandLineCommands = "\"hello\" \"" + tempFile + "\"";
t.StandardOutputImportance = "Low";
t.Execute().ShouldBeTrue();
t.ExitCode.ShouldBe(0);
engine.Errors.ShouldBe(0);
engine.AssertLogDoesntContain("hello world");
}
File.Delete(tempFile);
}
/// <summary>
/// StandardOutputImportance set to High should show up in our log
/// </summary>
[Fact]
public void OverrideStdOutImportanceToHigh()
{
string tempFile = FileUtilities.GetTemporaryFileName();
File.WriteAllText(tempFile, @"hello world");
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
engine.MinimumMessageImportance = MessageImportance.High;
t.BuildEngine = engine;
t.FullToolName = NativeMethodsShared.IsWindows ? "findstr.exe" : "grep";
t.MockCommandLineCommands = "\"hello\" \"" + tempFile + "\"";
t.StandardOutputImportance = "High";
t.Execute().ShouldBeTrue();
t.ExitCode.ShouldBe(0);
engine.Errors.ShouldBe(0);
engine.AssertLogContains("hello world");
}
File.Delete(tempFile);
}
[Theory]
[InlineData(true, "InvalidLevel")]
[InlineData(false, "InvalidLevel")]
public void FailToEnumerateStandardLoggingImportance(bool isErr, string invalidLevel)
{
using (MyTool t = new MyTool(AssemblyResources.SharedResources))
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
string toolName = NativeMethodsShared.IsWindows ? "cmd.exe" : "sh";
t.FullToolName = toolName;
if (isErr)
{
t.StandardErrorImportance = invalidLevel;
}
else
{
t.StandardOutputImportance = invalidLevel;
}
t.Execute().ShouldBeFalse();
t.ExitCode.ShouldBe(0);
engine.Errors.ShouldBe(1);
engine.AssertLogContains(invalidLevel);
}
}
/// <summary>
/// This is to ensure that somebody could write a task that implements ToolTask,
/// wraps some .EXE tool, and still have the ability to parse the stdout/stderr
/// himself. This is so that in case the tool doesn't log its errors in canonical
/// format, the task can still opt to do something reasonable with it.
/// </summary>
[Fact]
public void ToolTaskCanChangeCanonicalErrorFormat()
{
string tempFile = FileUtilities.GetTemporaryFileName();
File.WriteAllText(tempFile, @"
Main.cs(17,20): warning CS0168: The variable 'foo' is declared but never used.
BADTHINGHAPPENED: This is my custom error format that's not in canonical error format.
");
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
// The command we're giving is the command to spew the contents of the temp
// file we created above.
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? $"/C type \"{tempFile}\""
: $"-c \"cat \'{tempFile}\'\"";
t.Execute();
// The above command logged a canonical warning, as well as a custom error.
engine.AssertLogContains("CS0168");
engine.AssertLogContains("The variable 'foo' is declared but never used");
engine.AssertLogContains("BADTHINGHAPPENED");
engine.AssertLogContains("This is my custom error format");
engine.Warnings.ShouldBe(1); // "Expected one warning in log."
engine.Errors.ShouldBe(1); // "Expected one error in log."
}
File.Delete(tempFile);
}
/// <summary>
/// Passing env vars through the tooltask public property
/// </summary>
[Fact]
public void EnvironmentVariablesToToolTask()
{
using MyTool task = new MyTool();
task.BuildEngine = new MockEngine3();
string userVarName = NativeMethodsShared.IsWindows ? "username" : "user";
task.EnvironmentVariables = new[] { "a=b", "c=d", userVarName + "=x" /* built-in */, "path=" /* blank value */};
bool result = task.Execute();
result.ShouldBe(true);
task.ExecuteCalled.ShouldBe(true);
ProcessStartInfo startInfo = task.StartInfo;
startInfo.Environment["a"].ShouldBe("b");
startInfo.Environment["c"].ShouldBe("d");
startInfo.Environment[userVarName].ShouldBe("x");
startInfo.Environment["path"].ShouldBe(String.Empty);
if (NativeMethodsShared.IsWindows)
{
Assert.Equal(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
startInfo.Environment["programfiles"],
true);
}
}
/// <summary>
/// Equals sign in value
/// </summary>
[Fact]
public void EnvironmentVariablesToToolTaskEqualsSign()
{
using MyTool task = new MyTool();
task.BuildEngine = new MockEngine3();
task.EnvironmentVariables = new[] { "a=b=c" };
bool result = task.Execute();
result.ShouldBe(true);
task.StartInfo.Environment["a"].ShouldBe("b=c");
}
/// <summary>
/// No value provided
/// </summary>
[Fact]
public void EnvironmentVariablesToToolTaskInvalid1()
{
using MyTool task = new MyTool();
task.BuildEngine = new MockEngine3();
task.EnvironmentVariables = new[] { "x" };
bool result = task.Execute();
result.ShouldBe(false);
task.ExecuteCalled.ShouldBe(false);
}
/// <summary>
/// Empty string provided
/// </summary>
[Fact]
public void EnvironmentVariablesToToolTaskInvalid2()
{
using MyTool task = new MyTool();
task.BuildEngine = new MockEngine3();
task.EnvironmentVariables = new[] { "" };
bool result = task.Execute();
result.ShouldBe(false);
task.ExecuteCalled.ShouldBe(false);
}
/// <summary>
/// Empty name part provided
/// </summary>
[Fact]
public void EnvironmentVariablesToToolTaskInvalid3()
{
using MyTool task = new MyTool();
task.BuildEngine = new MockEngine3();
task.EnvironmentVariables = new[] { "=a;b=c" };
bool result = task.Execute();
result.ShouldBe(false);
task.ExecuteCalled.ShouldBe(false);
}
/// <summary>
/// Not set should not wipe out other env vars
/// </summary>
[Fact]
public void EnvironmentVariablesToToolTaskNotSet()
{
using MyTool task = new MyTool();
task.BuildEngine = new MockEngine3();
task.EnvironmentVariables = null;
bool result = task.Execute();
result.ShouldBe(true);
task.ExecuteCalled.ShouldBe(true);
Assert.True(task.StartInfo.Environment["PATH"].Length > 0);
}
/// <summary>
/// Verifies that if a directory with the same name of the tool exists that the tool task correctly
/// ignores the directory.
/// </summary>
[Fact]
public void ToolPathIsFoundWhenDirectoryExistsWithNameOfTool()
{
string toolName = NativeMethodsShared.IsWindows ? "cmd" : "sh";
string savedCurrentDirectory = Directory.GetCurrentDirectory();
try
{
using (var env = TestEnvironment.Create())
{
string tempDirectory = env.CreateFolder().Path;
env.SetCurrentDirectory(tempDirectory);
env.SetEnvironmentVariable("PATH", $"{tempDirectory}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
Directory.SetCurrentDirectory(tempDirectory);
string directoryNamedSameAsTool = Directory.CreateDirectory(Path.Combine(tempDirectory, toolName)).FullName;
using MyTool task = new MyTool
{
BuildEngine = new MockEngine3(),
FullToolName = toolName,
};
bool result = task.Execute();
Assert.NotEqual(directoryNamedSameAsTool, task.PathToToolUsed);
result.ShouldBeTrue();
}
}
finally
{
Directory.SetCurrentDirectory(savedCurrentDirectory);
}
}
/// <summary>
/// Confirms we can find a file on the PATH.
/// </summary>
[Fact]
public void FindOnPathSucceeds()
{
using MyTool tool = new MyTool();
string[] expectedCmdPath;
string shellName;
string cmdPath;
if (NativeMethodsShared.IsWindows)
{
expectedCmdPath = new[] { Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe").ToUpperInvariant() };
shellName = "cmd.exe";
cmdPath = tool.FindOnPath(shellName).ToUpperInvariant();
}
else
{
expectedCmdPath = new[] { "/bin/sh", "/usr/bin/sh" };
shellName = "sh";
cmdPath = tool.FindOnPath(shellName);
}
cmdPath.ShouldBeOneOf(expectedCmdPath);
}
/// <summary>
/// Equals sign in value
/// </summary>
[Fact]
public void GetProcessStartInfoCanOverrideEnvironmentVariables()
{
using MyTool task = new MyTool();
task.DoProcessStartInfoMutation = (p) => p.Environment.Remove("a");
task.BuildEngine = new MockEngine3();
task.EnvironmentVariables = new[] { "a=b" };
bool result = task.Execute();
result.ShouldBe(true);
task.StartInfo.Environment.ContainsKey("a").ShouldBe(false);
}
[Fact]
public void VisualBasicLikeEscapedQuotesInCommandAreNotMadeForwardSlashes()
{
using MyTool t = new MyTool();
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/C echo \"hello \\\"world\\\"\""
: "-c echo \"hello \\\"world\\\"\"";
t.Execute();
engine.AssertLogContains("echo \"hello \\\"world\\\"\"");
engine.Errors.ShouldBe(0);
}
private sealed class MyToolWithCustomProcess : MyTool
{
protected override Process StartToolProcess(Process proc)
{
#pragma warning disable CA2000 // Dispose objects before losing scope - caller needs the process
Process customProcess = new Process();
#pragma warning restore CA2000
customProcess.StartInfo = proc.StartInfo;
customProcess.EnableRaisingEvents = true;
customProcess.Exited += ReceiveExitNotification;
customProcess.ErrorDataReceived += ReceiveStandardErrorData;
customProcess.OutputDataReceived += ReceiveStandardOutputData;
return base.StartToolProcess(customProcess);
}
}
[Fact]
public void UsesCustomProcess()
{
using (MyToolWithCustomProcess t = new MyToolWithCustomProcess())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/C echo hello_stdout & echo hello_stderr >&2"
: "-c \"echo hello_stdout ; echo hello_stderr >&2\"";
t.Execute();
engine.AssertLogContains("\nhello_stdout");
engine.AssertLogContains("\nhello_stderr");
}
}
/// <summary>
/// Verifies that a ToolTask running under the command processor on Windows has autorun
/// disabled or enabled depending on an escape hatch.
/// </summary>
[Theory]
[InlineData("MSBUILDUSERAUTORUNINCMD", null, true)]
[InlineData("MSBUILDUSERAUTORUNINCMD", "0", true)]
[InlineData("MSBUILDUSERAUTORUNINCMD", "1", false)]
[Trait("Category", "nonosxtests")]
[Trait("Category", "nonlinuxtests")]
public void ExecTaskDisablesAutoRun(string environmentVariableName, string environmentVariableValue, bool autoRunShouldBeDisabled)
{
using (TestEnvironment testEnvironment = TestEnvironment.Create())
{
testEnvironment.SetEnvironmentVariable(environmentVariableName, environmentVariableValue);
ToolTaskThatGetsCommandLine task = new ToolTaskThatGetsCommandLine
{
UseCommandProcessor = true
};
task.Execute();
if (autoRunShouldBeDisabled)
{
task.CommandLineCommands.ShouldContain("/D ");
}
else
{
task.CommandLineCommands.ShouldNotContain("/D ");
}
}
}
/// <summary>
/// A simple implementation of <see cref="ToolTask"/> that allows tests to verify the command-line that was generated.
/// </summary>
private sealed class ToolTaskThatGetsCommandLine : ToolTask
{
protected override string ToolName => "cmd.exe";
protected override string GenerateFullPathToTool() => null;
protected override int ExecuteTool(string pathToTool, string responseFileCommands, string commandLineCommands)
{
PathToTool = pathToTool;
ResponseFileCommands = responseFileCommands;
CommandLineCommands = commandLineCommands;
return 0;
}
protected override void LogToolCommand(string message)
{
}
public string CommandLineCommands { get; private set; }
public string PathToTool { get; private set; }
public string ResponseFileCommands { get; private set; }
}
[Theory]
[InlineData("MSBUILDAVOIDUNICODE", null, false)]
[InlineData("MSBUILDAVOIDUNICODE", "0", false)]
[InlineData("MSBUILDAVOIDUNICODE", "1", true)]
public void ToolTaskCanUseUnicode(string environmentVariableName, string environmentVariableValue, bool expectNormalizationToANSI)
{
using TestEnvironment testEnvironment = TestEnvironment.Create(_output);
testEnvironment.SetEnvironmentVariable(environmentVariableName, environmentVariableValue);
var output = testEnvironment.ExpectFile();
MockEngine3 engine = new MockEngine3();
var task = new ToolTaskThatNeedsUnicode
{
BuildEngine = engine,
UseCommandProcessor = true,
OutputPath = output.Path,
};
task.Execute();
File.Exists(output.Path).ShouldBeTrue();
if (NativeMethodsShared.IsUnixLike // treat all UNIXy OSes as capable of UTF-8 everywhere
|| !expectNormalizationToANSI)
{
File.ReadAllText(output.Path).ShouldContain("łoł");
}
else
{
File.ReadAllText(output.Path).ShouldContain("lol");
}
}
private sealed class ToolTaskThatNeedsUnicode : ToolTask
{
protected override string ToolName => "cmd.exe";
[Required]
public string OutputPath { get; set; }
public ToolTaskThatNeedsUnicode()
{
UseCommandProcessor = true;
}
protected override string GenerateFullPathToTool()
{
return "cmd.exe";
}
protected override string GenerateCommandLineCommands()
{
return $"echo łoł > {OutputPath}";
}
}
/// <summary>
/// Verifies the validation of the <see cref="ToolTask.TaskProcessTerminationTimeout" />.
/// </summary>
/// <param name="timeout">New value for <see cref="ToolTask.TaskProcessTerminationTimeout" />.</param>
/// <param name="isInvalidValid">Is a task expected to be valid or not.</param>
[Theory]
[InlineData(int.MaxValue, false)]
[InlineData(97, false)]
[InlineData(0, false)]
[InlineData(-1, false)]
[InlineData(-2, true)]
[InlineData(-101, true)]
[InlineData(int.MinValue, true)]
public void SetsTerminationTimeoutCorrectly(int timeout, bool isInvalidValid)
{
using var env = TestEnvironment.Create(_output);
// Task under test:
var task = new ToolTaskSetsTerminationTimeout
{
BuildEngine = new MockEngine3()
};
task.TerminationTimeout = timeout;
task.ValidateParameters().ShouldBe(!isInvalidValid);
task.TerminationTimeout.ShouldBe(timeout);
}
/// <summary>
/// Verifies that a ToolTask instance can return correct results when executed multiple times with timeout.
/// </summary>
/// <param name="repeats">Specifies the number of repeats for external command execution.</param>
/// <param name="timeoutOnFirstExecution">Whether the first execution should be forced to time out before later retries succeed.</param>
/// <remarks>
/// These tests execute the same task instance multiple times, which will in turn run a command to sleep for a
/// predefined amount of time. The first execution may time out, but all following ones won't. It is expected
/// that all following executions return success.
/// </remarks>
[Theory]
[InlineData(1, false)]
[InlineData(3, false)]
[InlineData(3, true)]
public void ToolTaskThatTimeoutAndRetry(int repeats, bool timeoutOnFirstExecution)
{
using var env = TestEnvironment.Create(_output);
int fastDelayMilliseconds = 100;
int slowDelayMilliseconds = 5_000;
int timeoutMilliseconds = 2_000;
MockEngine3 engine = new();
// Task under test:
var task = new ToolTaskThatSleeps
{
BuildEngine = engine,
InitialDelay = timeoutOnFirstExecution ? slowDelayMilliseconds : fastDelayMilliseconds,
FollowupDelay = fastDelayMilliseconds,
Timeout = timeoutOnFirstExecution ? timeoutMilliseconds : System.Threading.Timeout.Infinite
};
// Execute the same task instance multiple times. The index is one-based.
for (int attempt = 1; attempt <= repeats; attempt++)
{
bool shouldSucceed = attempt > 1 || !timeoutOnFirstExecution;
bool result = task.Execute();
_output.WriteLine(
$"Attempt {attempt}/{repeats}: expectedSuccess={shouldSucceed}, actualSuccess={result}, exitCode={task.ExitCode}.");
if (!string.IsNullOrEmpty(engine.Log))
{
_output.WriteLine(engine.Log);
engine.Log = string.Empty;
}
task.RepeatCount.ShouldBe(attempt);
result.ShouldBe(shouldSucceed);
if (shouldSucceed)
{
task.ExitCode.ShouldBe(0);
}
else
{
task.ExitCode.ShouldNotBe(0);
}
}
}
/// <summary>
/// Verifies that ToolTask does not hang when the tool process spawns a grandchild
/// process that inherits stdout/stderr pipe handles and outlives the tool.
/// This is a regression test for https://github.com/dotnet/msbuild/issues/2981.
/// </summary>
[Fact]
public void ToolTaskDoesNotHangWhenGrandchildInheritsPipeHandles()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
// cmd echoes "hello", then starts a background ping that inherits
// pipe handles. cmd exits immediately; ping outlives the 2s EOF timeout.
t.MockCommandLineCommands = NativeMethodsShared.IsWindows
? "/c echo hello & start /b ping -n 10 127.0.0.1 > nul"
: "-c \"echo hello; sleep 10 &\"";
// Set a generous timeout - without the fix this would hang for the full ping duration
t.Timeout = 30000;
bool result = t.Execute();
// The tool should complete without hanging.
// The exit code may be non-zero depending on timing, but the key thing
// is that Execute() returns at all rather than hanging forever.
_output.WriteLine(engine.Log);
engine.Log.ShouldContain("hello");
}
}
/// <summary>
/// Verifies that ToolTask still captures all output from the tool process
/// even with the grandchild pipe fix enabled. This is a regression test for
/// https://github.com/dotnet/msbuild/issues/10378 where switching to
/// WaitForExit(int) caused output to be lost.
/// </summary>
[Fact]
public void ToolTaskCapturesAllOutputWithFix()
{
using (MyTool t = new MyTool())
{
MockEngine3 engine = new MockEngine3();
t.BuildEngine = engine;
// Echo multiple lines to verify all output is captured
t.MockCommandLineCommands = NativeMethodsShared.IsWindows ?
"/c echo line1 & echo line2 & echo line3"
: "-c \"echo line1; echo line2; echo line3\"";
bool result = t.Execute();
_output.WriteLine(engine.Log);
result.ShouldBeTrue();
engine.Log.ShouldContain("line1");
engine.Log.ShouldContain("line2");
engine.Log.ShouldContain("line3");
}
}
/// <summary>
/// A simple implementation of <see cref="ToolTask"/> to sleep for a while.
/// </summary>
/// <remarks>
/// This task invokes a direct sleep tool with a variable delay based on how many times the instance has been
/// executed, which avoids the flakiness of nesting the wait inside a shell.
/// </remarks>
private sealed class ToolTaskThatSleeps : ToolTask
{
private readonly string _pathToTool;
public ToolTaskThatSleeps()
: base()
{
// timeout.exe exits immediately when ToolTask redirects stdin on Windows, so use ping.exe
// as the built-in blocking process for the timeout/retry scenario.
_pathToTool = NativeMethodsShared.IsUnixLike
? "sleep"
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "ping.exe");
}
/// <summary>
/// Gets or sets the delay for the first execution.
/// </summary>
/// <remarks>
/// Defaults to 10 seconds.
/// </remarks>
public Int32 InitialDelay { get; set; } = 10000;
/// <summary>
/// Gets or sets the delay for the follow-up executions.
/// </summary>
/// <remarks>
/// Defaults to 100 milliseconds.
/// </remarks>
public Int32 FollowupDelay { get; set; } = 100;
/// <summary>
/// Int32 output parameter for the repeat counter for test purpose.
/// </summary>
[Output]
public Int32 RepeatCount { get; private set; } = 0;
/// <summary>
/// Gets the tool name.
/// </summary>
protected override string ToolName => Path.GetFileName(_pathToTool);
/// <summary>
/// Gets the full path to the sleep tool.
/// </summary>
protected override string GenerateFullPathToTool() => _pathToTool;
/// <summary>
/// Generates the arguments to sleep for a different amount of time based on repeat counter.
/// </summary>
protected override string GenerateCommandLineCommands()
{
int delay = RepeatCount < 2 ? InitialDelay : FollowupDelay;
return NativeMethodsShared.IsUnixLike
? (delay / 1000.0).ToString("0.###", CultureInfo.InvariantCulture)
: $"-n {Math.Max(2, (int)Math.Ceiling(delay / 1000.0) + 1).ToString(CultureInfo.InvariantCulture)} 127.0.0.1";
}
/// <summary>
/// Ensures that test parameters make sense.
/// </summary>
protected internal override bool ValidateParameters() =>
(InitialDelay > 0) && (FollowupDelay > 0) && base.ValidateParameters();
/// <summary>
/// Runs shell command to sleep for a while.
/// </summary>
/// <returns>
/// true if the task runs successfully; false otherwise.
/// </returns>
public override bool Execute()
{
RepeatCount++;
return base.Execute();
}
}
/// <summary>
/// A simple implementation of <see cref="ToolTask"/> to excercise <see cref="ToolTask.TaskProcessTerminationTimeout" />.
/// </summary>
private sealed class ToolTaskSetsTerminationTimeout : ToolTask
{
public ToolTaskSetsTerminationTimeout()
: base()
{
base.TaskResources = AssemblyResources.PrimaryResources;
}
/// <summary>
/// Gets or sets <see cref="ToolTask.TaskProcessTerminationTimeout" />.
/// </summary>
/// <remarks>
/// This is just a proxy property to access <see cref="ToolTask.TaskProcessTerminationTimeout" />.
/// </remarks>
public int TerminationTimeout
{
get => TaskProcessTerminationTimeout;
set => TaskProcessTerminationTimeout = value;
}
/// <summary>
/// Gets the tool name (dummy).
/// </summary>
protected override string ToolName => string.Empty;
/// <summary>
/// Gets the full path to tool (dummy).
/// </summary>
protected override string GenerateFullPathToTool() => string.Empty;
/// <summary>
/// Does nothing.
/// </summary>
/// <returns>
/// Always returns true.
/// </returns>
/// <remarks>
/// This dummy tool task is not meant to run anything.
/// </remarks>
public override bool Execute() => true;
}
/// <summary>
/// A ToolTask subclass for testing GetProcessStartInfo with TaskEnvironment.
/// </summary>
private sealed class MultiThreadedToolTask : ToolTask, IDisposable
{
private readonly string _fullToolName;
private readonly string _workingDirectory;
public MultiThreadedToolTask(string fullToolName, string workingDirectory)
{
_fullToolName = fullToolName;
_workingDirectory = workingDirectory;
}
public void Dispose() { }
protected override string ToolName => Path.GetFileName(_fullToolName);
protected override string GenerateFullPathToTool() => _fullToolName;
protected override string GetWorkingDirectory() => _workingDirectory;
/// <summary>
/// Exposes the protected GetProcessStartInfo for test verification.
/// </summary>
public ProcessStartInfo CallGetProcessStart(TaskEnvironment taskEnvironment)
{
TaskEnvironment = taskEnvironment;
return GetProcessStartInfo(
_fullToolName,
commandLineCommands: "/nologo",
responseFileSwitch: null);
}
/// <summary>
/// Exposes the protected DeleteTempFile for test verification.
/// </summary>
public void CallDeleteTempFile(string fileName) => DeleteTempFile(fileName);
/// <summary>
/// Exposes the protected GetProcessStartInfo for test verification.
/// </summary>
public ProcessStartInfo CallGetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch)
=> GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch);
}
[Fact]
public void GetProcessStartInfo_NoWorkingDirectoryOverride_UsesProjectDirectory()
{
// Arrange: no GetWorkingDirectory() override — WorkingDirectory should come from TaskEnvironment.
string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
var taskEnv = new TaskEnvironment(driver);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, null);
tool.BuildEngine = new MockEngine(_output);
// Act
ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
// Assert
result.WorkingDirectory.ShouldBe(projectDir,
"Without a GetWorkingDirectory() override, WorkingDirectory should fall back to taskEnvironment.ProjectDirectory");
}
[Fact]
public void GetProcessStartInfo_PropagatesSpecificEnvironmentVariable()
{
// Arrange: create a driver with a known env var and verify it appears in ProcessStartInfo.
string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
var envVars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["MY_CUSTOM_VAR"] = "custom_value"
};
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir, envVars);
var taskEnv = new TaskEnvironment(driver);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, null);
tool.BuildEngine = new MockEngine(_output);
// Act
ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
// Assert
result.Environment["MY_CUSTOM_VAR"].ShouldBe("custom_value",
"Environment variables from TaskEnvironment should be propagated to ProcessStartInfo");
}
[Fact]
public void GetProcessStartInfo_RelativeWorkingDirectory_AbsolutizedAgainstProjectDir()
{
// Arrange: GetWorkingDirectory() returns a relative path — should be absolutized against project dir.
string projectDir = NativeMethodsShared.IsUnixLike ? "/projects/myapp" : @"C:\Projects\MyApp";
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
var taskEnv = new TaskEnvironment(driver);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, "subdir");
tool.BuildEngine = new MockEngine(_output);
// Act
ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
// Assert: relative path should be combined with the project directory.
string expected = Path.Combine(projectDir, "subdir");
result.WorkingDirectory.ShouldBe(expected,
"A relative GetWorkingDirectory() result should be absolutized against taskEnvironment.ProjectDirectory");
}
[Fact]
public void GetProcessStartInfo_AbsoluteWorkingDirectory_UsesOverridePath()
{
// Arrange: GetWorkingDirectory() returns an absolute path — should be used directly.
string projectDir = NativeMethodsShared.IsUnixLike ? "/projects/myapp" : @"C:\Projects\MyApp";
string overrideDir = NativeMethodsShared.IsUnixLike ? "/custom/workdir" : @"D:\Custom\WorkDir";
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
var taskEnv = new TaskEnvironment(driver);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, overrideDir);
tool.BuildEngine = new MockEngine(_output);
// Act
ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
// Assert: absolute path should be used as-is (Path.Combine with absolute second arg returns it).
result.WorkingDirectory.ShouldBe(overrideDir,
"An absolute GetWorkingDirectory() result should be used directly, not combined with project directory");
}
[Fact]
public void GetProcessStartInfo_TaskEnvironmentVariablesOverride()
{
// Arrange: create a driver with a custom env var.
string expectedWorkingDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
var envVars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["MY_VAR"] = "from_driver",
["PATH"] = "driver_path"
};
using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir, envVars);
var taskEnv = new TaskEnvironment(driver);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, null);
tool.BuildEngine = new MockEngine(_output);
// Set EnvironmentVariables on the task (should override the driver's value).
tool.EnvironmentVariables = ["MY_VAR=from_task_override"];
// Act
ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
// Assert: task-level override should win.
result.Environment["MY_VAR"].ShouldBe("from_task_override",
"EnvironmentVariables property on the task should override TaskEnvironment values");
}
[Fact]
public void GetProcessStartInfo_MultiProcessDriver_BackwardCompat()
{
// Arrange: use the default MultiProcessTaskEnvironmentDriver (non-multithreaded mode).
// With the default driver, no working directory is set
// (the process inherits the parent's CWD), and process environment is inherited.
var taskEnv = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, null);
tool.BuildEngine = new MockEngine(_output);
// Act
ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
// Assert: with MultiProcessTaskEnvironmentDriver, WorkingDirectory should be empty
// (process inherits parent CWD) — matching pre-migration behavior.
result.WorkingDirectory.ShouldBeEmpty(
"MultiProcessTaskEnvironmentDriver should not set WorkingDirectory, preserving old inherit-from-parent behavior");
result.FileName.ShouldBe(toolPath);
result.Arguments.ShouldContain("/nologo");
}
[Fact]
public void GetProcessStartInfo_EmptyWorkingDirectory_KeepsProjectDirectory()
{
// Arrange: GetWorkingDirectory() returns empty string — should NOT override project dir.
// GetProcessStartInfo checks !string.IsNullOrEmpty, so empty string should leave
// the project directory from TaskEnvironment intact.
string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
var taskEnv = new TaskEnvironment(driver);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, string.Empty);
tool.BuildEngine = new MockEngine(_output);
// Act
ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
// Assert: empty-string GetWorkingDirectory() must not overwrite the project directory.
result.WorkingDirectory.ShouldBe(projectDir,
"Empty-string from GetWorkingDirectory() should not override the project directory from TaskEnvironment");
}
[Fact]
public void FindOnPath_UsesTaskEnvironmentPath()
{
// Arrange: create a temp dir with a dummy file, set TaskEnvironment PATH to that dir.
using var env = TestEnvironment.Create(_output);
string tempDir = env.CreateFolder().Path;
string toolName = NativeMethodsShared.IsWindows ? "mytesttool.exe" : "mytesttool";
File.WriteAllText(Path.Combine(tempDir, toolName), "dummy");
string projectDir = tempDir;
var envVars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PATH"] = tempDir
};
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir, envVars);
var taskEnv = new TaskEnvironment(driver);
string fullToolName = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(fullToolName, null);
tool.TaskEnvironment = taskEnv;
tool.BuildEngine = new MockEngine(_output);
// Act
string result = tool.FindOnPath(toolName);
// Assert: should find the tool via TaskEnvironment's PATH.
result.ShouldNotBeNull("FindOnPath should find the tool via TaskEnvironment's PATH");
result.ShouldBe(Path.Combine(tempDir, toolName));
}
[Fact]
public void DeleteTempFile_UsesTaskEnvironmentForAbsolutePath()
{
// Arrange: create a temp file in the project directory, use relative path for deletion.
using var env = TestEnvironment.Create(_output);
string projectDir = env.CreateFolder().Path;
string fileName = "tempfile.rsp";
string fullPath = Path.Combine(projectDir, fileName);
File.WriteAllText(fullPath, "test content");
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
var taskEnv = new TaskEnvironment(driver);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, null);
tool.TaskEnvironment = taskEnv;
tool.BuildEngine = new MockEngine(_output);
// Act: delete using a relative path — TaskEnvironment should absolutize it.
tool.CallDeleteTempFile(fileName);
// Assert
File.Exists(fullPath).ShouldBeFalse(
"DeleteTempFile should have deleted the file using TaskEnvironment-absolutized path");
}
[Fact]
public void GetProcessStartInfo_MultiThreadedDriver_SetsWorkingDirectoryAndEnvironment()
{
// Arrange: when TaskEnvironment uses MultiThreadedTaskEnvironmentDriver,
// GetProcessStartInfo should set WorkingDirectory from the driver's ProjectDirectory
// and propagate environment variables.
string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
var taskEnv = new TaskEnvironment(driver);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, null);
tool.TaskEnvironment = taskEnv;
tool.BuildEngine = new MockEngine(_output);
// Act: call through the virtual GetProcessStartInfo (the normal entry point).
ProcessStartInfo result = tool.CallGetProcessStartInfo(toolPath, "/nologo", null);
// Assert: WorkingDirectory should be set to project directory
// and environment variables should be propagated from the driver.
result.WorkingDirectory.ShouldBe(projectDir,
"MultiThreadedDriver should set WorkingDirectory to ProjectDirectory");
result.Environment.Count.ShouldBeGreaterThan(0,
"MultiThreadedDriver should propagate environment variables");
}
[Fact]
public void GetProcessStartInfo_MultiProcessDriver_DoesNotSetWorkingDirectory()
{
// Arrange: when TaskEnvironment uses the default MultiProcessTaskEnvironmentDriver,
// WorkingDirectory should not be set (the process inherits the parent's CWD).
var taskEnv = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);
string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
using var tool = new MultiThreadedToolTask(toolPath, null);
tool.TaskEnvironment = taskEnv;
tool.BuildEngine = new MockEngine(_output);
// Act
ProcessStartInfo result = tool.CallGetProcessStartInfo(toolPath, "/nologo", null);
// Assert: WorkingDirectory should be empty (inherits from parent process).
result.WorkingDirectory.ShouldBeNullOrEmpty(
"MultiProcessDriver should not set WorkingDirectory, preserving pre-migration behavior");
}
[Fact]
public void ComputePathToTool_UsesTaskEnvironmentForFileExistence()
{
// Arrange: create a temp dir with a dummy tool, set up TaskEnvironment pointing there.
using var env = TestEnvironment.Create(_output);
string projectDir = env.CreateFolder().Path;
string toolDir = env.CreateFolder().Path;
string toolName = NativeMethodsShared.IsWindows ? "mytool.exe" : "mytool";
string toolFullPath = Path.Combine(toolDir, toolName);
File.WriteAllText(toolFullPath, "dummy");
using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
var taskEnv = new TaskEnvironment(driver);
// Use MyTool pointing to the actual tool location.
using var tool = new MyTool();
tool.FullToolName = toolFullPath;
tool.TaskEnvironment = taskEnv;
tool.BuildEngine = new MockEngine(_output);
// Act: Execute triggers ComputePathToTool which uses TaskEnvironment.GetAbsolutePath
// for file existence checks. The tool exists at an absolute path, so this should succeed.
bool result = tool.Execute();
// Assert: the tool should have been found and executed.
tool.ExecuteCalled.ShouldBeTrue(
"ComputePathToTool should find the tool using TaskEnvironment-absolutized path for existence check");
}
}
}
|