diff --git a/NUnitConsole.sln b/NUnitConsole.sln index ba6dec794..068f86830 100644 --- a/NUnitConsole.sln +++ b/NUnitConsole.sln @@ -144,6 +144,9 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NUnit3.10", "src\TestData\NUnit3.10\NUnit3.10.csproj", "{0555B97D-E918-455B-951C-74EFCDA8790A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "NUnitCommon", "NUnitCommon", "{3B30D2E5-1587-4D68-B848-1BDDB3C24BFC}" + ProjectSection(SolutionItems) = preProject + src\NUnitCommon\Directory.Build.props = src\NUnitCommon\Directory.Build.props + EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "nunit.extensibility.api", "src\NUnitCommon\nunit.extensibility.api\nunit.extensibility.api.csproj", "{71DE0F2C-C72B-4CBF-99BE-F2DC0FBEDA24}" EndProject diff --git a/src/NUnitCommon/nunit.agent.core.tests/AgentOptionTests.cs b/src/NUnitCommon/nunit.agent.core.tests/AgentOptionTests.cs new file mode 100644 index 000000000..42236ea9b --- /dev/null +++ b/src/NUnitCommon/nunit.agent.core.tests/AgentOptionTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt + +using System; +using NUnit.Engine; +using NUnit.Framework; + +namespace NUnit.Agents +{ + public class AgentOptionTests + { + static TestCaseData[] DefaultSettings = new[] + { + new TestCaseData("AgentId", Guid.Empty), + new TestCaseData("AgencyUrl", string.Empty), + new TestCaseData("AgencyPid", string.Empty), + new TestCaseData("DebugAgent", false), + new TestCaseData("DebugTests", false), + new TestCaseData("TraceLevel", InternalTraceLevel.Off), + new TestCaseData("WorkDirectory", string.Empty) + }; + + [TestCaseSource(nameof(DefaultSettings))] + public void DefaultOptionSettings(string propertyName, T defaultValue) + { + var options = new AgentOptions(); + var prop = typeof(AgentOptions).GetProperty(propertyName); + Assert.That(prop, Is.Not.Null, $"Property {propertyName} does not exist"); + Assert.That(prop.GetValue(options, new object[0]), Is.EqualTo(defaultValue)); + } + + static readonly Guid AGENT_GUID = Guid.NewGuid(); + static readonly TestCaseData[] ValidSettings = new[] + { + // Boolean options - no values provided + new TestCaseData("--debug-agent", "DebugAgent", true), + new TestCaseData("--debug-tests", "DebugTests", true), + // Options with values - using '=' as delimiter + new TestCaseData($"--agentId={AGENT_GUID}", "AgentId", AGENT_GUID), + new TestCaseData("--agencyUrl=THEURL", "AgencyUrl", "THEURL"), + new TestCaseData("--pid=1234", "AgencyPid", "1234"), + new TestCaseData("--trace=Info", "TraceLevel", InternalTraceLevel.Info), + new TestCaseData("--work=WORKDIR", "WorkDirectory", "WORKDIR"), + // Options with values - using ':' as delimiter + new TestCaseData("--trace:Error", "TraceLevel", InternalTraceLevel.Error), + new TestCaseData("--work:WORKDIR", "WorkDirectory", "WORKDIR"), + // Value with spaces (provided OS passes them through) + new TestCaseData("--work:MY WORK DIR", "WorkDirectory", "MY WORK DIR"), + }; + + [TestCaseSource(nameof(ValidSettings))] + public void ValidOptionSettings(string option, string propertyName, T expectedValue) + { + var options = new AgentOptions(option); + var prop = typeof(AgentOptions).GetProperty(propertyName); + Assert.That(prop, Is.Not.Null, $"Property {propertyName} does not exist"); + Assert.That(prop.GetValue(options, new object[0]), Is.EqualTo(expectedValue)); + } + + [Test] + public void MultipleOptions() + { + var options = new AgentOptions("--debug-tests", "--trace=Info", "--work", "MYWORKDIR"); + Assert.That(options.DebugAgent, Is.False); + Assert.That(options.DebugTests); + Assert.That(options.TraceLevel, Is.EqualTo(InternalTraceLevel.Info)); + Assert.That(options.WorkDirectory, Is.EqualTo("MYWORKDIR")); + } + + [Test] + public void FileNameSupplied() + { + var filename = GetType().Assembly.Location; + var options = new AgentOptions(filename); + Assert.That(options.Files.Count, Is.EqualTo(1)); + Assert.That(options.Files[0], Is.EqualTo(filename)); + } + } +} diff --git a/src/NUnitCommon/nunit.agent.core/AgentOptions.cs b/src/NUnitCommon/nunit.agent.core/AgentOptions.cs new file mode 100644 index 000000000..7aeb4a8e5 --- /dev/null +++ b/src/NUnitCommon/nunit.agent.core/AgentOptions.cs @@ -0,0 +1,124 @@ +// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt + +using NUnit.Engine; +using System; +using System.Collections.Generic; +using System.IO; + +namespace NUnit.Agents +{ + /// + /// All agents, either built-in or pluggable, must be able to + /// handle the options defined in this class. In some cases, + /// it may be permissible to ignore them but they should never + /// give rise to an error. + /// + public class AgentOptions + { + static readonly char[] DELIMS = new[] { '=', ':' }; + // Dictionary containing valid options with bool value true if a value is required. + static readonly Dictionary VALID_OPTIONS = new Dictionary(); + + static AgentOptions() + { + VALID_OPTIONS["agentId"] = true; + VALID_OPTIONS["agencyUrl"] = true; + VALID_OPTIONS["debug-agent"] = false; + VALID_OPTIONS["debug-tests"] = false; + VALID_OPTIONS["trace"] = true; + VALID_OPTIONS["pid"] = true; + VALID_OPTIONS["work"] = true; + } + + public AgentOptions(params string[] args) + { + int index; + for (index = 0; index < args.Length; index++) + { + string arg = args[index]; + + if (IsOption(arg)) + { + var option = arg.Substring(2); + var delim = option.IndexOfAny(DELIMS); + var opt = option; + string? val = null; + if (delim > 0) + { + opt = option.Substring(0, delim); + val = option.Substring(delim + 1); + } + + // Simultaneously check that the option is valid and determine if it takes an argument + if (!VALID_OPTIONS.TryGetValue(opt, out bool optionTakesValue)) + throw new Exception($"Invalid argument: {arg}"); + + if (optionTakesValue) + { + if (val == null && index + 1 < args.Length) + val = args[++index]; + + if (val == null) + throw new Exception($"Option requires a value: {arg}"); + } + else if (delim > 0) + { + throw new Exception($"Option does not take a value: {arg}"); + } + + if (opt == "agentId") + AgentId = new Guid(GetArgumentValue(arg)); + else if (opt == "agencyUrl") + AgencyUrl = GetArgumentValue(arg); + else if (opt == "debug-agent") + DebugAgent = true; + else if (opt == "debug-tests") + DebugTests = true; + else if (opt == "trace") + TraceLevel = (InternalTraceLevel)Enum.Parse(typeof(InternalTraceLevel), val.ShouldNotBeNull()); + else if (opt == "pid") + AgencyPid = val.ShouldNotBeNull(); + else if (opt == "work") + WorkDirectory = val.ShouldNotBeNull(); + else + throw new Exception($"Invalid argument: {arg}"); + } + else if (File.Exists(arg)) + Files.Add(arg); + else + throw new FileNotFoundException($"FileNotFound: {arg}"); + } + + if (Files.Count > 1) + throw new ArgumentException($"Only one file argument is allowed but {Files.Count} were supplied"); + + string GetArgumentValue(string argument) + { + var delim = argument.IndexOfAny(DELIMS); + + if (delim > 0) + return argument.Substring(delim + 1); + + if (index + 1 < args.Length) + return args[++index]; + + throw new Exception($"Option requires a value: {argument}"); + } + } + + public Guid AgentId { get; } = Guid.Empty; + public string AgencyUrl { get; } = string.Empty; + public string AgencyPid { get; } = string.Empty; + public bool DebugTests { get; } = false; + public bool DebugAgent { get; } = false; + public InternalTraceLevel TraceLevel { get; } = InternalTraceLevel.Off; + public string WorkDirectory { get; } = string.Empty; + + public List Files { get; } = new List(); + + private static bool IsOption(string arg) + { + return arg.StartsWith("--", StringComparison.Ordinal); + } + } +} diff --git a/src/NUnitCommon/nunit.agent.core/NUnitAgent.cs b/src/NUnitCommon/nunit.agent.core/NUnitAgent.cs new file mode 100644 index 000000000..4e516e6a6 --- /dev/null +++ b/src/NUnitCommon/nunit.agent.core/NUnitAgent.cs @@ -0,0 +1,134 @@ +// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt + +using System; +using System.Diagnostics; +using System.IO; +using System.Security; +using System.Reflection; +using NUnit.Engine.Agents; + +#if NETFRAMEWORK +using NUnit.Engine.Communication.Transports.Remoting; +#else +using NUnit.Engine.Communication.Transports.Tcp; +#endif + +namespace NUnit.Agents +{ + public class NUnitAgent + { + static Process? AgencyProcess; + static RemoteTestAgent? Agent; + static readonly int _pid = Process.GetCurrentProcess().Id; + static readonly Logger log = InternalTrace.GetLogger(typeof(TestAgent)); + + /// + /// The main entry point for the application. + /// + [STAThread] + public static void Execute(string[] args) + { + var options = new AgentOptions(args); + var logName = $"nunit-agent_{_pid}.log"; + + InternalTrace.Initialize(Path.Combine(options.WorkDirectory, logName), options.TraceLevel); + log.Info($"{typeof(TAgent).Name} process {_pid} starting"); + log.Info($" Agent Path: {Assembly.GetExecutingAssembly().Location}"); + + if (options.DebugAgent || options.DebugTests) + TryLaunchDebugger(); + + log.Info($" AgentId: {options.AgentId}"); + log.Info($" AgencyUrl: {options.AgencyUrl}"); + log.Info($" AgencyPid: {options.AgencyPid}"); + + if (!string.IsNullOrEmpty(options.AgencyPid)) + LocateAgencyProcess(options.AgencyPid); + + log.Info("Starting RemoteTestAgent"); + Agent = new RemoteTestAgent(options.AgentId); +#if NETFRAMEWORK + Agent.Transport = new TestAgentRemotingTransport(Agent, options.AgencyUrl); +#else + Agent.Transport = new TestAgentTcpTransport(Agent, options.AgencyUrl); +#endif + + try + { + if (Agent.Start()) + WaitForStop(Agent, AgencyProcess.ShouldNotBeNull()); + else + { + log.Error("Failed to start RemoteTestAgent"); + Environment.Exit(AgentExitCodes.FAILED_TO_START_REMOTE_AGENT); + } + } + catch (Exception ex) + { + log.Error("Exception in RemoteTestAgent. {0}", ExceptionHelper.BuildMessageAndStackTrace(ex)); + Environment.Exit(AgentExitCodes.UNEXPECTED_EXCEPTION); + } + log.Info("Agent process {0} exiting cleanly", _pid); + + Environment.Exit(AgentExitCodes.OK); + } + + private static void LocateAgencyProcess(string agencyPid) + { + var agencyProcessId = int.Parse(agencyPid); + try + { + AgencyProcess = Process.GetProcessById(agencyProcessId); + } + catch (Exception e) + { + log.Error($"Unable to connect to agency process with PID: {agencyProcessId}"); + log.Error($"Failed with exception: {e.Message} {e.StackTrace}"); + Environment.Exit(AgentExitCodes.UNABLE_TO_LOCATE_AGENCY); + } + } + + private static void WaitForStop(RemoteTestAgent agent, Process agencyProcess) + { + log.Debug("Waiting for stopSignal"); + + while (!agent.WaitForStop(500)) + { + if (agencyProcess.HasExited) + { + log.Error("Parent process has been terminated."); + Environment.Exit(AgentExitCodes.PARENT_PROCESS_TERMINATED); + } + } + + log.Debug("Stop signal received"); + } + + private static void TryLaunchDebugger() + { + if (Debugger.IsAttached) + return; + + try + { + Debugger.Launch(); + } + catch (SecurityException se) + { + if (InternalTrace.Initialized) + { + log.Error($"System.Security.Permissions.UIPermission is not set to start the debugger. {se} {se.StackTrace}"); + } + Environment.Exit(AgentExitCodes.DEBUGGER_SECURITY_VIOLATION); + } + catch (NotImplementedException nie) //Debugger is not implemented on mono + { + if (InternalTrace.Initialized) + { + log.Error($"Debugger is not available on all platforms. {nie} {nie.StackTrace}"); + } + Environment.Exit(AgentExitCodes.DEBUGGER_NOT_IMPLEMENTED); + } + } + } +} diff --git a/src/NUnitEngine/agents/nunit-agent-net462-x86/Net462X86Agent.cs b/src/NUnitEngine/agents/nunit-agent-net462-x86/Net462X86Agent.cs new file mode 100644 index 000000000..34cd2fffa --- /dev/null +++ b/src/NUnitEngine/agents/nunit-agent-net462-x86/Net462X86Agent.cs @@ -0,0 +1,9 @@ +// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt + +namespace NUnit.Agents +{ + public class Net462X86Agent : NUnitAgent + { + public static void Main(string[] args) => NUnitAgent.Execute(args); + } +} diff --git a/src/NUnitEngine/agents/nunit-agent-net462-x86/nunit-agent-net462-x86.csproj b/src/NUnitEngine/agents/nunit-agent-net462-x86/nunit-agent-net462-x86.csproj index 3a464ee91..51b9a8dd2 100644 --- a/src/NUnitEngine/agents/nunit-agent-net462-x86/nunit-agent-net462-x86.csproj +++ b/src/NUnitEngine/agents/nunit-agent-net462-x86/nunit-agent-net462-x86.csproj @@ -20,10 +20,6 @@ - - - - nunit.ico @@ -47,9 +43,9 @@ - + - + \ No newline at end of file diff --git a/src/NUnitEngine/agents/nunit-agent-net462/Net462Agent.cs b/src/NUnitEngine/agents/nunit-agent-net462/Net462Agent.cs new file mode 100644 index 000000000..5b229101f --- /dev/null +++ b/src/NUnitEngine/agents/nunit-agent-net462/Net462Agent.cs @@ -0,0 +1,9 @@ +// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt + +namespace NUnit.Agents +{ + public class Net462Agent : NUnitAgent + { + public static void Main(string[] args) => NUnitAgent.Execute(args); + } +} diff --git a/src/NUnitEngine/agents/nunit-agent-net462/nunit-agent-net462.csproj b/src/NUnitEngine/agents/nunit-agent-net462/nunit-agent-net462.csproj index 39d03ecb7..b1ca0350a 100644 --- a/src/NUnitEngine/agents/nunit-agent-net462/nunit-agent-net462.csproj +++ b/src/NUnitEngine/agents/nunit-agent-net462/nunit-agent-net462.csproj @@ -19,10 +19,6 @@ - - - - nunit.ico diff --git a/src/NUnitEngine/agents/nunit-agent-net80/Net80Agent.cs b/src/NUnitEngine/agents/nunit-agent-net80/Net80Agent.cs new file mode 100644 index 000000000..d18935ed0 --- /dev/null +++ b/src/NUnitEngine/agents/nunit-agent-net80/Net80Agent.cs @@ -0,0 +1,9 @@ +// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt + +namespace NUnit.Agents +{ + public class Net80Agent : NUnitAgent + { + public static void Main(string[] args) => NUnitAgent.Execute(args); + } +} diff --git a/src/NUnitEngine/agents/nunit-agent-net80/nunit-agent-net80.csproj b/src/NUnitEngine/agents/nunit-agent-net80/nunit-agent-net80.csproj index f1b7796f4..980e7d831 100644 --- a/src/NUnitEngine/agents/nunit-agent-net80/nunit-agent-net80.csproj +++ b/src/NUnitEngine/agents/nunit-agent-net80/nunit-agent-net80.csproj @@ -20,10 +20,6 @@ - - - - nunit.ico diff --git a/src/NUnitEngine/nunit.engine.tests/Services/AgentProcessTests.cs b/src/NUnitEngine/nunit.engine.tests/Services/AgentProcessTests.cs index e2d695c61..f7b53b1f3 100644 --- a/src/NUnitEngine/nunit.engine.tests/Services/AgentProcessTests.cs +++ b/src/NUnitEngine/nunit.engine.tests/Services/AgentProcessTests.cs @@ -15,7 +15,7 @@ public class AgentProcessTests private TestPackage _package; private readonly static Guid AGENT_ID = Guid.NewGuid(); private const string REMOTING_URL = "tcp://127.0.0.1:1234/TestAgency"; - private readonly string REQUIRED_ARGS = $"{AGENT_ID} {REMOTING_URL} --pid={Process.GetCurrentProcess().Id}"; + private readonly string REQUIRED_ARGS = $"--agentId={AGENT_ID} --agencyUrl={REMOTING_URL} --pid={Process.GetCurrentProcess().Id}"; [SetUp] public void SetUp() diff --git a/src/NUnitEngine/nunit.engine/Services/AgentProcess.cs b/src/NUnitEngine/nunit.engine/Services/AgentProcess.cs index b7d6dce45..99a997b0b 100644 --- a/src/NUnitEngine/nunit.engine/Services/AgentProcess.cs +++ b/src/NUnitEngine/nunit.engine/Services/AgentProcess.cs @@ -27,7 +27,7 @@ public AgentProcess(TestAgency agency, TestPackage package, Guid agentId) string workDirectory = package.GetSetting(EnginePackageSettings.WorkDirectory, string.Empty); string agencyUrl = TargetRuntime.Runtime == Runtime.NetCore ? agency.TcpEndPoint : agency.RemotingUrl; - AgentArgs = new StringBuilder($"{agentId} {agencyUrl} --pid={Process.GetCurrentProcess().Id}"); + AgentArgs = new StringBuilder($"--agentId={agentId} --agencyUrl={agencyUrl} --pid={Process.GetCurrentProcess().Id}"); // Set options that need to be in effect before the package // is loaded by using the command line.