using System; using System.Reflection; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Moq; using NUnit.Framework; using Robust.Client; using Robust.Client.Interfaces; using Robust.Server; using Robust.Server.Console; using Robust.Server.Interfaces; using Robust.Server.Interfaces.Console; using Robust.Server.Interfaces.ServerStatus; using Robust.Shared.ContentPack; using Robust.Shared.Interfaces.Network; using Robust.Shared.Interfaces.Timing; using Robust.Shared.IoC; using Robust.Shared.Timing; using FrameEventArgs = Robust.Shared.Timing.FrameEventArgs; using ServerProgram = Robust.Server.Program; namespace Robust.UnitTesting { /// /// Base class allowing you to implement integration tests. /// /// /// Integration tests allow you to act upon a running server as a whole, /// contrary to unit testing which tests, well, units. /// public abstract partial class RobustIntegrationTest { /// /// Start an instance of the server and return an object that can be used to control it. /// protected virtual ServerIntegrationInstance StartServer(ServerIntegrationOptions options = null) { return new ServerIntegrationInstance(options); } /// /// Start a headless instance of the client and return an object that can be used to control it. /// protected virtual ClientIntegrationInstance StartClient(ClientIntegrationOptions options = null) { return new ClientIntegrationInstance(options); } /// /// Provides control over a running instance of the client or server. /// /// /// The instance executes in another thread. /// As such, sending commands to it purely queues them to be ran asynchronously. /// To ensure that the instance is idle, i.e. not executing code and finished all queued commands, /// you can use . /// This method must be used before trying to access any state like , /// to prevent race conditions. /// public abstract class IntegrationInstance { private protected Thread InstanceThread; private protected IDependencyCollection DependencyCollection; private protected readonly ChannelReader _toInstanceReader; private protected readonly ChannelWriter _toInstanceWriter; private protected readonly ChannelReader _fromInstanceReader; private protected readonly ChannelWriter _fromInstanceWriter; private int _currentTicksId = 1; private int _ackTicksId; private bool _isSurelyIdle; private bool _isAlive = true; private Exception _unhandledException; /// /// Whether the instance is still alive. /// "Alive" indicates that it is able to receive and process commands. /// /// /// Thrown if you did not ensure that the instance is idle via first. /// public bool IsAlive { get { if (!_isSurelyIdle) { throw new InvalidOperationException( "Cannot read this without ensuring that the instance is idle."); } return _isAlive; } } /// /// If the server /// /// /// Thrown if you did not ensure that the instance is idle via first. /// public Exception UnhandledException { get { if (!_isSurelyIdle) { throw new InvalidOperationException( "Cannot read this without ensuring that the instance is idle."); } return _unhandledException; } } private protected IntegrationInstance() { var toInstance = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); _toInstanceReader = toInstance.Reader; _toInstanceWriter = toInstance.Writer; var fromInstance = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); _fromInstanceReader = fromInstance.Reader; _fromInstanceWriter = fromInstance.Writer; } /// /// Resolve a dependency inside the instance. /// This works identical to . /// /// /// Thrown if you did not ensure that the instance is idle via first. /// public T ResolveDependency() { if (!_isSurelyIdle) { throw new InvalidOperationException( "Cannot resolve services without ensuring that the instance is idle."); } return DependencyCollection.Resolve(); } /// /// Wait for the instance to go idle, either through finishing all commands or shutting down/crashing. /// /// /// If true, throw an exception if the server dies on an unhandled exception. /// /// /// /// Thrown if is true and the instance shuts down on an unhandled exception. /// public async Task WaitIdleAsync(bool throwOnUnhandled = true, CancellationToken cancellationToken = default) { while (_isAlive && _currentTicksId != _ackTicksId) { var msg = await _fromInstanceReader.ReadAsync(cancellationToken); switch (msg) { case ShutDownMessage shutDownMessage: { _isAlive = false; _isSurelyIdle = true; _unhandledException = shutDownMessage.UnhandledException; if (throwOnUnhandled && _unhandledException != null) { throw new Exception("Waiting instance shut down with unhandled exception", _unhandledException); } break; } case AckTicksMessage ack: { _ackTicksId = ack.MessageId; break; } case AssertFailMessage assertFailMessage: { // Rethrow exception without losing stack trace. ExceptionDispatchInfo.Capture(assertFailMessage.Exception).Throw(); break; // Unreachable. } } } _isSurelyIdle = true; } /// /// Queue for the server to run n ticks. /// /// The amount of ticks to run. public void RunTicks(int ticks) { _isSurelyIdle = false; _currentTicksId += 1; _toInstanceWriter.TryWrite(new RunTicksMessage(ticks, 1 / 60f, _currentTicksId)); } /// /// Queue for the server to be stopped. /// public void Stop() { _isSurelyIdle = false; // Won't get ack'd directly but the shutdown is convincing enough. _currentTicksId += 1; _toInstanceWriter.TryWrite(new StopMessage()); } /// /// Queue for a delegate to be ran inside the main loop of the instance. /// /// /// Do not run NUnit assertions inside . Use instead. /// public void Post(Action post) { _isSurelyIdle = false; _currentTicksId += 1; _toInstanceWriter.TryWrite(new PostMessage(post, _currentTicksId)); } /// /// Queue for a delegate to be ran inside the main loop of the instance, /// rethrowing any exceptions in . /// /// /// Exceptions raised inside this callback will be rethrown by . /// This makes it ideal for NUnit assertions, /// since rethrowing the NUnit assertion directly provides less noise. /// public void Assert(Action assertion) { _isSurelyIdle = false; _currentTicksId += 1; _toInstanceWriter.TryWrite(new AssertMessage(assertion, _currentTicksId)); } } public sealed class ServerIntegrationInstance : IntegrationInstance { private readonly ServerIntegrationOptions _options; internal ServerIntegrationInstance(ServerIntegrationOptions options) { _options = options; InstanceThread = new Thread(_serverMain) {Name = "Server Instance Thread"}; DependencyCollection = new DependencyCollection(); InstanceThread.Start(); } private void _serverMain() { try { IoCManager.InitThread(DependencyCollection); ServerIoC.RegisterIoC(); IoCManager.Register(true); IoCManager.Register(true); IoCManager.Register(true); IoCManager.Register(true); IoCManager.Register(true); IoCManager.Register(true); IoCManager.RegisterInstance(new Mock().Object, true); _options?.InitIoC?.Invoke(); IoCManager.BuildGraph(); ServerProgram.SetupLogging(); ServerProgram.InitReflectionManager(); var server = DependencyCollection.Resolve(); if (_options?.ServerContentAssembly != null) { IoCManager.Resolve().ServerContentAssembly = _options.ServerContentAssembly; } if (_options?.SharedContentAssembly != null) { IoCManager.Resolve().SharedContentAssembly = _options.SharedContentAssembly; } _options?.BeforeStart?.Invoke(); if (server.Start()) { throw new Exception("Server failed to start."); } var gameLoop = new IntegrationGameLoop( DependencyCollection.Resolve(), _fromInstanceWriter, _toInstanceReader); server.OverrideMainLoop(gameLoop); server.MainLoop(); } catch (Exception e) { _fromInstanceWriter.TryWrite(new ShutDownMessage(e)); return; } _fromInstanceWriter.TryWrite(new ShutDownMessage(null)); } } public sealed class ClientIntegrationInstance : IntegrationInstance { private readonly ClientIntegrationOptions _options; internal ClientIntegrationInstance(ClientIntegrationOptions options) { _options = options; InstanceThread = new Thread(_clientMain) {Name = "Client Instance Thread"}; DependencyCollection = new DependencyCollection(); InstanceThread.Start(); } /// /// Wire up the server to connect to when gets called. /// public void SetConnectTarget(ServerIntegrationInstance server) { var clientNetManager = ResolveDependency(); var serverNetManager = server.ResolveDependency(); if (!serverNetManager.IsRunning) { throw new InvalidOperationException("Server net manager is not running!"); } clientNetManager.NextConnectChannel = serverNetManager.MessageChannelWriter; } private void _clientMain() { try { IoCManager.InitThread(DependencyCollection); ClientIoC.RegisterIoC(GameController.DisplayMode.Headless); IoCManager.Register(true); IoCManager.Register(true); IoCManager.Register(true); IoCManager.Register(true); IoCManager.Register(true); _options?.InitIoC?.Invoke(); IoCManager.BuildGraph(); GameController.RegisterReflection(); var client = DependencyCollection.Resolve(); if (_options?.ClientContentAssembly != null) { IoCManager.Resolve().ClientContentAssembly = _options.ClientContentAssembly; } if (_options?.SharedContentAssembly != null) { IoCManager.Resolve().SharedContentAssembly = _options.SharedContentAssembly; } client.LoadConfigAndUserData = false; _options?.BeforeStart?.Invoke(); client.Startup(); var gameLoop = new IntegrationGameLoop(DependencyCollection.Resolve(), _fromInstanceWriter, _toInstanceReader); client.OverrideMainLoop(gameLoop); client.MainLoop(GameController.DisplayMode.Headless); } catch (Exception e) { _fromInstanceWriter.TryWrite(new ShutDownMessage(e)); return; } _fromInstanceWriter.TryWrite(new ShutDownMessage(null)); } } // Synchronization between the integration instance and the main loop is done purely through message passing. // The main thread sends commands like "run n ticks" and the main loop reports back the commands it has finished. // It also reports when it dies, of course. internal sealed class IntegrationGameLoop : IGameLoop { private readonly IGameTiming _gameTiming; private readonly ChannelWriter _channelWriter; private readonly ChannelReader _channelReader; #pragma warning disable 67 public event EventHandler Input; public event EventHandler Tick; public event EventHandler Update; public event EventHandler Render; #pragma warning restore 67 public bool SingleStep { get; set; } public bool Running { get; set; } public int MaxQueuedTicks { get; set; } public SleepMode SleepMode { get; set; } public IntegrationGameLoop(IGameTiming gameTiming, ChannelWriter channelWriter, ChannelReader channelReader) { _gameTiming = gameTiming; _channelWriter = channelWriter; _channelReader = channelReader; } public void Run() { // Ack tick message 1 is implied as "init done" _channelWriter.TryWrite(new AckTicksMessage(1)); Running = true; Tick += (a, b) => Console.WriteLine("tick: {0}", _gameTiming.CurTick); _gameTiming.InSimulation = true; while (Running) { var message = _channelReader.ReadAsync().AsTask().Result; switch (message) { case RunTicksMessage msg: _gameTiming.InSimulation = true; var simFrameEvent = new FrameEventArgs(msg.Delta); for (var i = 0; i < msg.Ticks && Running; i++) { _gameTiming.CurTick = new GameTick(_gameTiming.CurTick.Value + 1); Tick?.Invoke(this, simFrameEvent); } _channelWriter.TryWrite(new AckTicksMessage(msg.MessageId)); break; case StopMessage _: Running = false; break; case PostMessage postMessage: postMessage.Post(); _channelWriter.TryWrite(new AckTicksMessage(postMessage.MessageId)); break; case AssertMessage assertMessage: try { assertMessage.Assertion(); } catch (Exception e) { _channelWriter.TryWrite(new AssertFailMessage(e)); } _channelWriter.TryWrite(new AckTicksMessage(assertMessage.MessageId)); break; } } } } public class ServerIntegrationOptions : IntegrationOptions { public Assembly ServerContentAssembly { get; set; } } public class ClientIntegrationOptions : IntegrationOptions { public Assembly ClientContentAssembly { get; set; } } public abstract class IntegrationOptions { public Action InitIoC { get; set; } public Action BeforeStart { get; set; } public Assembly SharedContentAssembly { get; set; } } /// /// Sent head -> instance to tell the instance to run a few simulation ticks. /// private sealed class RunTicksMessage { public RunTicksMessage(int ticks, float delta, int messageId) { Ticks = ticks; Delta = delta; MessageId = messageId; } public int Ticks { get; } public float Delta { get; } public int MessageId { get; } } /// /// Sent head -> instance to tell the instance to shut down cleanly. /// private sealed class StopMessage { } /// /// Sent instance -> head to confirm finishing of ticks message. /// private sealed class AckTicksMessage { public AckTicksMessage(int messageId) { MessageId = messageId; } public int MessageId { get; } } private sealed class AssertFailMessage { public Exception Exception { get; } public AssertFailMessage(Exception exception) { Exception = exception; } } /// /// Sent instance -> head when instance shuts down for whatever reason. /// private sealed class ShutDownMessage { public ShutDownMessage(Exception unhandledException) { UnhandledException = unhandledException; } public Exception UnhandledException { get; } } private sealed class PostMessage { public Action Post { get; } public int MessageId { get; } public PostMessage(Action post, int messageId) { Post = post; MessageId = messageId; } } private sealed class AssertMessage { public Action Assertion { get; } public int MessageId { get; } public AssertMessage(Action assertion, int messageId) { Assertion = assertion; MessageId = messageId; } } } }