using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Linq;
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.Server;
using Robust.Server.Console;
using Robust.Server.ServerStatus;
using Robust.Shared;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Network;
using Robust.Shared.Timing;
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
{
private readonly List _integrationInstances = new();
///
/// Start an instance of the server and return an object that can be used to control it.
///
protected virtual ServerIntegrationInstance StartServer(ServerIntegrationOptions? options = null)
{
var instance = new ServerIntegrationInstance(options);
_integrationInstances.Add(instance);
return instance;
}
///
/// 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)
{
var instance = new ClientIntegrationInstance(options);
_integrationInstances.Add(instance);
return instance;
}
[OneTimeTearDown]
public async Task TearDown()
{
_integrationInstances.ForEach(p => p.Stop());
await Task.WhenAll(_integrationInstances.Select(p => p.WaitIdleAsync()));
_integrationInstances.Clear();
}
///
/// 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 : IDisposable
{
private protected Thread InstanceThread = default!;
private protected IDependencyCollection DependencyCollection = default!;
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.
///
[Pure]
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)
{
ExceptionDispatchInfo.Capture(_unhandledException).Throw();
return;
}
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));
}
///
/// followed by
///
public async Task WaitRunTicks(int ticks)
{
RunTicks(ticks);
await WaitIdleAsync();
}
///
/// 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));
}
public async Task WaitPost(Action post)
{
Post(post);
await WaitIdleAsync();
}
///
/// 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 async Task WaitAssertion(Action assertion)
{
Assert(assertion);
await WaitIdleAsync();
}
public void Dispose()
{
Stop();
}
}
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.Register(true);
IoCManager.RegisterInstance(new Mock().Object, true);
_options?.InitIoC?.Invoke();
IoCManager.BuildGraph();
//ServerProgram.SetupLogging();
ServerProgram.InitReflectionManager();
var server = DependencyCollection.Resolve();
server.LoadConfigAndUserData = false;
if (_options?.ContentAssemblies != null)
{
IoCManager.Resolve().Assemblies = _options.ContentAssemblies;
}
var cfg = IoCManager.Resolve();
if (_options != null)
{
_options.BeforeStart?.Invoke();
cfg.OverrideConVars(_options.CVarOverrides.Select(p => (p.Key, p.Value)));
if (_options.ExtraPrototypes != null)
{
IoCManager.Resolve()
.MountString("/Prototypes/__integration_extra.yml", _options.ExtraPrototypes);
}
}
cfg.OverrideConVars(new []{("log.runtimelog", "false"), (CVars.SysWinTickPeriod.Name, "-1")});
var failureLevel = _options == null ? LogLevel.Error : _options.FailureLogLevel;
server.ContentStart = _options?.ContentStart ?? false;
if (server.Start(() => new TestLogHandler("SERVER", failureLevel)))
{
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);
IoCManager.Register(true);
_options?.InitIoC?.Invoke();
IoCManager.BuildGraph();
GameController.RegisterReflection();
var client = DependencyCollection.Resolve();
if (_options?.ContentAssemblies != null)
{
IoCManager.Resolve().Assemblies = _options.ContentAssemblies;
}
client.LoadConfigAndUserData = false;
var cfg = IoCManager.Resolve();
if (_options != null)
{
_options.BeforeStart?.Invoke();
cfg.OverrideConVars(_options.CVarOverrides.Select(p => (p.Key, p.Value)));
if (_options.ExtraPrototypes != null)
{
IoCManager.Resolve()
.MountString("/Prototypes/__integration_extra.yml", _options.ExtraPrototypes);
}
}
cfg.OverrideConVars(new []{(CVars.NetPredictLagBias.Name, "0")});
var gameLoop = new IntegrationGameLoop(DependencyCollection.Resolve(),
_fromInstanceWriter, _toInstanceReader);
var failureLevel = _options == null ? LogLevel.Error : _options.FailureLogLevel;
client.OverrideMainLoop(gameLoop);
client.ContentStart = true;
client.Run(GameController.DisplayMode.Headless, () => new TestLogHandler("CLIENT", failureLevel));
}
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;
_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++)
{
Input?.Invoke(this, simFrameEvent);
Tick?.Invoke(this, simFrameEvent);
_gameTiming.CurTick = new GameTick(_gameTiming.CurTick.Value + 1);
Update?.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 class ClientIntegrationOptions : IntegrationOptions
{
}
public abstract class IntegrationOptions
{
public Action? InitIoC { get; set; }
public Action? BeforeStart { get; set; }
public Assembly[]? ContentAssemblies { get; set; }
public string? ExtraPrototypes { get; set; }
public LogLevel? FailureLogLevel { get; set; } = LogLevel.Error;
public bool ContentStart { get; set; } = false;
public Dictionary CVarOverrides { get; } = new();
}
///
/// 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;
}
}
}
}