using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.Contracts;
using System.IO;
using System.Linq;
using System.Net;
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.Console;
using Robust.Client.Timing;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.XAML.Proxy;
using Robust.Server;
using Robust.Server.Console;
using Robust.Server.GameStates;
using Robust.Server.ServerStatus;
using Robust.Shared;
using Robust.Shared.Asynchronous;
using Robust.Shared.Configuration;
using Robust.Shared.Console;
using Robust.Shared.ContentPack;
using Robust.Shared.Enums;
using Robust.Shared.GameObjects;
using Robust.Shared.Input;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Reflection;
using Robust.Shared.Serialization;
using Robust.Shared.Testing;
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
{
internal static readonly ConcurrentQueue ClientsReady = new();
internal static readonly ConcurrentQueue ServersReady = new();
internal static readonly ConcurrentQueue ClientsCreated = new();
internal static readonly ConcurrentQueue ClientsPooled = new();
internal static readonly ConcurrentQueue ClientsNotPooled = new();
internal static readonly ConcurrentQueue ServersCreated = new();
internal static readonly ConcurrentQueue ServersPooled = new();
internal static readonly ConcurrentQueue ServersNotPooled = new();
private readonly List _notPooledInstances = new();
private readonly ConcurrentDictionary _clientsRunning = new();
private readonly ConcurrentDictionary _serversRunning = new();
private string TestId => TestContext.CurrentContext.Test.FullName;
private string GetTestsRanString(IntegrationInstance instance, string running)
{
var type = instance is ServerIntegrationInstance ? "Server " : "Client ";
return $"{type} tests ran ({instance.TestsRan.Count}):\n" +
$"{string.Join('\n', instance.TestsRan)}\n" +
$"Currently running: {running}";
}
///
/// Start an instance of the server and return an object that can be used to control it.
///
protected virtual ServerIntegrationInstance StartServer(ServerIntegrationOptions? options = null)
{
options ??= new ServerIntegrationOptions();
options.TestAssembly = GetType().Assembly;
ServerIntegrationInstance instance;
if (ShouldPool(options))
{
if (ServersReady.TryDequeue(out var server))
{
server.PreviousOptions = server.ServerOptions;
server.ServerOptions = options;
OnServerReturn(server).Wait();
// Ensure the instance is properly idle to avoid inconsistencies in behavior
// between pooled and non-pooled returns.
server.MarkNonIdle();
_serversRunning[server] = 0;
instance = server;
}
else
{
instance = new ServerIntegrationInstance(options);
_serversRunning[instance] = 0;
ServersCreated.Enqueue(TestId);
}
ServersPooled.Enqueue(TestId);
}
else
{
instance = new ServerIntegrationInstance(options);
_notPooledInstances.Add(instance);
ServersCreated.Enqueue(TestId);
ServersNotPooled.Enqueue(TestId);
}
var currentTest = TestContext.CurrentContext.Test.FullName;
TestContext.Out.WriteLine(GetTestsRanString(instance, currentTest));
instance.TestsRan.Add(currentTest);
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)
{
options ??= new ClientIntegrationOptions();
options.TestAssembly = GetType().Assembly;
ClientIntegrationInstance instance;
if (ShouldPool(options))
{
if (ClientsReady.TryDequeue(out var client))
{
client.PreviousOptions = client.ClientOptions;
client.ClientOptions = options;
OnClientReturn(client).Wait();
// Ensure the instance is properly idle to avoid inconsistencies in behavior
// between pooled and non-pooled returns.
client.MarkNonIdle();
_clientsRunning[client] = 0;
instance = client;
}
else
{
instance = new ClientIntegrationInstance(options);
_clientsRunning[instance] = 0;
ClientsCreated.Enqueue(TestId);
}
ClientsPooled.Enqueue(TestId);
}
else
{
instance = new ClientIntegrationInstance(options);
_notPooledInstances.Add(instance);
ClientsCreated.Enqueue(TestId);
ClientsNotPooled.Enqueue(TestId);
}
var currentTest = TestContext.CurrentContext.Test.FullName;
TestContext.Out.WriteLine(GetTestsRanString(instance, currentTest));
instance.TestsRan.Add(currentTest);
return instance;
}
///
/// Connects a client integration instance to a server integration instance.
///
protected static async Task ConnectClient(
ServerIntegrationInstance server,
ClientIntegrationInstance client,
string? userName = null)
{
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
Assert.DoesNotThrow(() => client.SetConnectTarget(server));
await client.WaitPost(() => ((IClientNetManager) client.NetMan).ClientConnect(null!, 0, userName!));
}
///
/// Starts a connected client/server pair.
///
protected async Task StartConnectedPair(
ServerIntegrationOptions? serverOptions = null,
ClientIntegrationOptions? clientOptions = null,
string? userName = null)
{
var server = StartServer(serverOptions);
var client = StartClient(clientOptions);
await ConnectClient(server, client, userName);
return new ConnectedIntegrationPair(server, client);
}
///
/// Runs the server and client in lockstep.
///
protected static async Task RunTicksSync(
ServerIntegrationInstance server,
ClientIntegrationInstance client,
int ticks)
{
for (var i = 0; i < ticks; i++)
{
await server.WaitRunTicks(1);
await client.WaitRunTicks(1);
}
}
///
/// Disconnects a client integration instance from its server and runs both sides long enough to process it.
///
protected static async Task DisconnectClient(
ServerIntegrationInstance server,
ClientIntegrationInstance client,
string reason = "")
{
await client.WaitPost(() => ((IClientNetManager) client.NetMan).ClientDisconnect(reason));
await RunTicksSync(server, client, 5);
}
protected sealed class ConnectedIntegrationPair : IAsyncDisposable
{
public ServerIntegrationInstance Server { get; }
public ClientIntegrationInstance Client { get; }
private bool _disposed;
public ConnectedIntegrationPair(ServerIntegrationInstance server, ClientIntegrationInstance client)
{
Server = server;
Client = client;
}
public void Deconstruct(out ClientIntegrationInstance client, out ServerIntegrationInstance server)
{
client = Client;
server = Server;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
_disposed = true;
await DisconnectClient(Server, Client);
}
}
private bool ShouldPool(IntegrationOptions? options)
{
// If no options are provided, we assume we should pool
if (options == null)
return true;
// If custom options are provided without explicitly setting pool=true, we assume we shouldn't pool.
if (options is not {Pool: true})
return false;
if (!options.Asynchronous)
throw new Exception("Invalid options. Pooled instances must be asynchronous");
return true;
}
protected virtual async Task OnInstanceReturn(IntegrationInstance instance)
{
await instance.WaitPost(() =>
{
var config = instance.InstanceDependencyCollection.Resolve();
var overrides = new[]
{
(RTCVars.FailureLogLevel.Name, (instance.Options?.FailureLogLevel ?? RTCVars.FailureLogLevel.DefaultValue).ToString())
};
config.OverrideConVars(overrides);
});
}
protected virtual Task OnClientReturn(ClientIntegrationInstance client)
{
return OnInstanceReturn(client);
}
protected virtual Task OnServerReturn(ServerIntegrationInstance server)
{
return OnInstanceReturn(server);
}
[OneTimeTearDown]
public async Task OneTimeTearDown()
{
foreach (var client in _clientsRunning.Keys)
{
await ReturnToPool(client);
}
_clientsRunning.Clear();
foreach (var server in _serversRunning.Keys)
{
await ReturnToPool(server);
}
_serversRunning.Clear();
_notPooledInstances.ForEach(p => p.Stop());
await Task.WhenAll(_notPooledInstances.Select(p => p.WaitIdleAsync()));
_notPooledInstances.Clear();
}
public async Task ReturnToPool(ClientIntegrationInstance client)
{
if (!_clientsRunning.Remove(client, out _))
return;
var res = await ReturnToPoolInternal(client);
if (res)
ClientsReady.Enqueue(client);
}
public async Task ReturnToPool(ServerIntegrationInstance server)
{
if (!_serversRunning.Remove(server, out _))
return;
var res = await ReturnToPoolInternal(server);
if (res)
ServersReady.Enqueue(server);
}
public async Task ReturnToPoolInternal(IntegrationInstance instance)
{
await instance.WaitIdleAsync();
if (instance.UnhandledException != null || !instance.IsAlive)
return false;
var netMan = instance.ResolveDependency();
Assert.That(netMan.IsConnected, Is.False);
// TODO Validate cvars and whatnot
// Or just move content's PoolManager & TestPair over to engine.
await instance.WaitPost(() => instance.EntMan.FlushEntities());
await instance.WaitIdleAsync();
return instance.UnhandledException == null && instance.IsAlive;
}
///
/// 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 : IIntegrationInstance
{
private protected Thread? InstanceThread;
private protected IDependencyCollection DependencyCollection = default!;
private protected IntegrationGameLoop GameLoop = default!;
private protected readonly ChannelReader