Files
RobustToolbox/Robust.UnitTesting/RobustIntegrationTest.cs
PJB3005 788e9386fd Split up test project
Robust.UnitTesting was both ALL tests for RT, and also API surface for content tests.

Tests are now split into separate projects as appropriate, and the API side has also been split off.
2025-12-16 01:36:53 +01:00

1332 lines
50 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.IO;
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.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
{
/// <summary>
/// Base class allowing you to implement integration tests.
/// </summary>
/// <remarks>
/// Integration tests allow you to act upon a running server as a whole,
/// contrary to unit testing which tests, well, units.
/// </remarks>
public abstract partial class RobustIntegrationTest
{
internal static readonly ConcurrentQueue<ClientIntegrationInstance> ClientsReady = new();
internal static readonly ConcurrentQueue<ServerIntegrationInstance> ServersReady = new();
internal static readonly ConcurrentQueue<string> ClientsCreated = new();
internal static readonly ConcurrentQueue<string> ClientsPooled = new();
internal static readonly ConcurrentQueue<string> ClientsNotPooled = new();
internal static readonly ConcurrentQueue<string> ServersCreated = new();
internal static readonly ConcurrentQueue<string> ServersPooled = new();
internal static readonly ConcurrentQueue<string> ServersNotPooled = new();
private readonly List<IntegrationInstance> _notPooledInstances = new();
private readonly ConcurrentDictionary<ClientIntegrationInstance, byte> _clientsRunning = new();
private readonly ConcurrentDictionary<ServerIntegrationInstance, byte> _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}";
}
/// <summary>
/// Start an instance of the server and return an object that can be used to control it.
/// </summary>
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;
}
/// <summary>
/// Start a headless instance of the client and return an object that can be used to control it.
/// </summary>
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;
}
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<IConfigurationManagerInternal>();
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<bool> ReturnToPoolInternal(IntegrationInstance instance)
{
await instance.WaitIdleAsync();
if (instance.UnhandledException != null || !instance.IsAlive)
return false;
var netMan = instance.ResolveDependency<INetManager>();
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;
}
/// <summary>
/// Provides control over a running instance of the client or server.
/// </summary>
/// <remarks>
/// 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 <see cref="WaitIdleAsync"/>.
/// This method must be used before trying to access any state like <see cref="ResolveDependency{T}"/>,
/// to prevent race conditions.
/// </remarks>
public abstract class IntegrationInstance : IIntegrationInstance
{
private protected Thread? InstanceThread;
private protected IDependencyCollection DependencyCollection = default!;
private protected IntegrationGameLoop GameLoop = default!;
private protected readonly ChannelReader<object> _toInstanceReader;
private protected readonly ChannelWriter<object> _toInstanceWriter;
private protected readonly ChannelReader<object> _fromInstanceReader;
private protected readonly ChannelWriter<object> _fromInstanceWriter;
private protected TextWriter _testOut;
private int _currentTicksId = 1;
private int _ackTicksId;
private bool _isSurelyIdle;
private bool _isAlive = true;
private Exception? _unhandledException;
public IDependencyCollection InstanceDependencyCollection => DependencyCollection;
public virtual IntegrationOptions? Options { get; internal set; }
public EntityManager EntMan { get; private set; } = default!;
public IPrototypeManager ProtoMan { get; private set; } = default!;
public IConfigurationManager CfgMan { get; private set; } = default!;
public ISharedPlayerManager PlayerMan { get; private set; } = default!;
public INetManager NetMan { get; private set; } = default!;
public IGameTiming Timing { get; private set; } = default!;
public IMapManager MapMan { get; private set; } = default!;
public IConsoleHost ConsoleHost { get; private set; } = default!;
public ISawmill Log { get; private set; } = default!;
protected virtual void ResolveIoC(IDependencyCollection deps)
{
EntMan = deps.Resolve<EntityManager>();
ProtoMan = deps.Resolve<IPrototypeManager>();
CfgMan = deps.Resolve<IConfigurationManager>();
PlayerMan = deps.Resolve<ISharedPlayerManager>();
Timing = deps.Resolve<IGameTiming>();
NetMan = deps.Resolve<INetManager>();
MapMan = deps.Resolve<IMapManager>();
ConsoleHost = deps.Resolve<IConsoleHost>();
Log = deps.Resolve<ILogManager>().GetSawmill("test");
}
[Pure]
public T System<T>() where T : IEntitySystem
{
CheckThreadOrIdle();
return EntMan.System<T>();
}
[Pure]
public T Resolve<T>() => ResolveDependency<T>();
public TransformComponent Transform(EntityUid uid)
{
CheckThreadOrIdle();
return EntMan.GetComponent<TransformComponent>(uid);
}
public MetaDataComponent MetaData(EntityUid uid)
{
CheckThreadOrIdle();
return EntMan.GetComponent<MetaDataComponent>(uid);
}
public MetaDataComponent MetaData(NetEntity uid)
=> MetaData(EntMan.GetEntity(uid));
public TransformComponent Transform(NetEntity uid)
=> Transform(EntMan.GetEntity(uid));
public async Task ExecuteCommand(string cmd)
{
await WaitPost(() => ConsoleHost.ExecuteCommand(cmd));
}
/// <inheritdoc/>
public bool IsAlive
{
get
{
CheckThreadOrIdle();
return _isAlive;
}
}
/// <summary>
/// If the server
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown if you did not ensure that the instance is idle via <see cref="WaitIdleAsync"/> first.
/// </exception>
public Exception? UnhandledException
{
get
{
CheckThreadOrIdle();
return _unhandledException;
}
}
public List<string> TestsRan { get; } = new();
private protected IntegrationInstance(IntegrationOptions? options)
{
Options = options;
_testOut = TestContext.Out;
var toInstance = Channel.CreateUnbounded<object>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = true
});
_toInstanceReader = toInstance.Reader;
_toInstanceWriter = toInstance.Writer;
var fromInstance = Channel.CreateUnbounded<object>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = true
});
_fromInstanceReader = fromInstance.Reader;
_fromInstanceWriter = fromInstance.Writer;
}
/// <summary>
/// Resolve a dependency inside the instance.
/// This works identical to <see cref="IoCManager.Resolve{T}()"/>.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown if you did not ensure that the instance is idle via <see cref="WaitIdleAsync"/> first.
/// </exception>
[Pure]
public T ResolveDependency<T>()
{
CheckThreadOrIdle();
return DependencyCollection.Resolve<T>();
}
/// <inheritdoc/>
public Task WaitIdleAsync(bool throwOnUnhandled = true, CancellationToken cancellationToken = default)
{
if (Options?.Asynchronous != false)
{
return WaitIdleImplAsync(throwOnUnhandled, cancellationToken);
}
WaitIdleImplSync(throwOnUnhandled);
return Task.CompletedTask;
}
private async Task WaitIdleImplAsync(bool throwOnUnhandled, CancellationToken cancellationToken)
{
while (_isAlive && _currentTicksId != _ackTicksId)
{
object msg = default!;
try
{
msg = await _fromInstanceReader.ReadAsync(cancellationToken);
}
catch(OperationCanceledException ex)
{
_unhandledException = ex;
_isAlive = false;
break;
}
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;
}
private void WaitIdleImplSync(bool throwOnUnhandled)
{
var oldSyncContext = SynchronizationContext.Current;
try
{
// Set up thread-local resources for instance switch.
{
var taskMgr = DependencyCollection.Resolve<TaskManager>();
IoCManager.InitThread(DependencyCollection, replaceExisting: true);
taskMgr.ResetSynchronizationContext();
}
GameLoop.SingleThreadRunUntilEmpty();
_isSurelyIdle = true;
while (_fromInstanceReader.TryRead(out var msg))
{
switch (msg)
{
case ShutDownMessage shutDownMessage:
{
_isAlive = false;
_unhandledException = shutDownMessage.UnhandledException;
if (throwOnUnhandled && _unhandledException != null)
{
ExceptionDispatchInfo.Capture(_unhandledException).Throw();
return;
}
break;
}
case AssertFailMessage assertFailMessage:
{
// Rethrow exception without losing stack trace.
ExceptionDispatchInfo.Capture(assertFailMessage.Exception).Throw();
break; // Unreachable.
}
}
}
_isSurelyIdle = true;
}
finally
{
// NUnit has its own synchronization context so let's *not* break everything.
SynchronizationContext.SetSynchronizationContext(oldSyncContext);
}
}
/// <inheritdoc/>
public void RunTicks(int ticks)
{
_isSurelyIdle = false;
_currentTicksId += 1;
_toInstanceWriter.TryWrite(new RunTicksMessage(ticks, _currentTicksId));
}
/// <inheritdoc/>
public async Task WaitRunTicks(int ticks)
{
RunTicks(ticks);
await WaitIdleAsync();
}
/// <summary>
/// Queue for the server to be stopped.
/// </summary>
public void Stop()
{
_isSurelyIdle = false;
// Won't get ack'd directly but the shutdown is convincing enough.
_currentTicksId += 1;
_toInstanceWriter.TryWrite(new StopMessage());
_toInstanceWriter.TryComplete();
}
/// <inheritdoc/>
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();
}
/// <inheritdoc/>
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();
}
internal void MarkNonIdle()
{
Post(() => {});
}
public virtual Task Cleanup() => Task.CompletedTask;
public void Dispose()
{
Stop();
}
protected void LoadExtraPrototypes(IDependencyCollection deps, IntegrationOptions options)
{
var resMan = deps.Resolve<IResourceManagerInternal>();
if (options.ExtraPrototypes != null)
{
resMan.MountString("/Prototypes/__integration_extra.yml", options.ExtraPrototypes);
}
if (options.ExtraPrototypeList is {} list)
{
for (var i = 0; i < list.Count; i++)
{
resMan.MountString($"/Prototypes/__integration_extra_{i}.yml", list[i]);
}
}
}
private void CheckThreadOrIdle()
{
if (_isSurelyIdle)
return;
if (Thread.CurrentThread == InstanceThread)
return;
throw new InvalidOperationException(
"Cannot perform this operation without ensuring the instance is idle.");
}
}
public sealed class ServerIntegrationInstance : IntegrationInstance, IServerIntegrationInstance
{
public ServerIntegrationInstance(ServerIntegrationOptions? options) : base(options)
{
ServerOptions = options;
DependencyCollection = new DependencyCollection();
if (options?.Asynchronous != false)
{
InstanceThread = new Thread(_serverMain)
{
Name = "Server Instance Thread",
IsBackground = true
};
InstanceThread.Start();
}
else
{
Init();
}
}
public override IntegrationOptions? Options
{
get => ServerOptions;
internal set => ServerOptions = (ServerIntegrationOptions?) value;
}
public ServerIntegrationOptions? ServerOptions { get; internal set; }
public ServerIntegrationOptions? PreviousOptions { get; internal set; }
private void _serverMain()
{
try
{
var server = Init();
GameLoop.Run();
server.FinishMainLoop();
IoCManager.Clear();
}
catch (Exception e)
{
_fromInstanceWriter.TryWrite(new ShutDownMessage(e));
return;
}
_fromInstanceWriter.TryWrite(new ShutDownMessage(null));
}
private BaseServer Init()
{
var deps = DependencyCollection;
IoCManager.InitThread(deps, replaceExisting: true);
ServerIoC.RegisterIoC(deps);
deps.Register<INetManager, IntegrationNetManager>(true);
deps.Register<IServerNetManager, IntegrationNetManager>(true);
deps.Register<IntegrationNetManager, IntegrationNetManager>(true);
deps.Register<ISystemConsoleManager, SystemConsoleManagerDummy>(true);
deps.Register<IModLoader, TestingModLoader>(true);
deps.Register<IModLoaderInternal, TestingModLoader>(true);
deps.Register<TestingModLoader, TestingModLoader>(true);
deps.RegisterInstance<IStatusHost>(new Mock<IStatusHost>().Object, true);
deps.Register<IRobustMappedStringSerializer, IntegrationMappedStringSerializer>(true);
deps.Register<IServerConsoleHost, TestingServerConsoleHost>(true);
deps.Register<IConsoleHost, TestingServerConsoleHost>(true);
deps.Register<IConsoleHostInternal, TestingServerConsoleHost>(true);
Options?.InitIoC?.Invoke();
deps.BuildGraph();
//ServerProgram.SetupLogging();
ServerProgram.InitReflectionManager(deps);
if (Options?.LoadTestAssembly != false && Options?.TestAssembly != null)
deps.Resolve<IReflectionManager>().LoadAssemblies(Options.TestAssembly);
var server = DependencyCollection.Resolve<BaseServer>();
var serverOptions = ServerOptions?.Options ?? new ServerOptions()
{
LoadConfigAndUserData = false,
LoadContentResources = false,
};
// Autoregister components if options are null or we're NOT starting from content, as in that case
// components will get auto-registered later. But either way, we will still invoke
// BeforeRegisterComponents here.
Options?.BeforeRegisterComponents?.Invoke();
if (!Options?.ContentStart ?? true)
{
var componentFactory = deps.Resolve<IComponentFactory>();
componentFactory.DoAutoRegistrations();
componentFactory.GenerateNetIds();
}
if (Options?.ContentAssemblies != null)
{
deps.Resolve<TestingModLoader>().Assemblies = Options.ContentAssemblies;
}
var cfg = deps.Resolve<IConfigurationManagerInternal>();
cfg.LoadCVarsFromAssembly(typeof(RobustIntegrationTest).Assembly);
cfg.LoadCVarsFromAssembly(typeof(RTCVars).Assembly);
if (Options != null)
{
Options.BeforeStart?.Invoke();
cfg.OverrideConVars(Options.CVarOverrides.Select(p => (p.Key, p.Value)));
LoadExtraPrototypes(deps, Options);
}
cfg.OverrideConVars(new[]
{
("log.runtimelog", "false"),
(CVars.SysWinTickPeriod.Name, "-1"),
(CVars.SysGCCollectStart.Name, "false"),
(RTCVars.FailureLogLevel.Name, (Options?.FailureLogLevel ?? RTCVars.FailureLogLevel.DefaultValue).ToString()),
(CVars.ResCheckBadFileExtensions.Name, "false")
});
cfg.SetVirtualConfig();
server.ContentStart = Options?.ContentStart ?? false;
var logHandler = Options?.OverrideLogHandler ?? (() => new TestLogHandler(cfg, "SERVER", _testOut));
if (server.Start(serverOptions, logHandler))
{
throw new Exception("Server failed to start.");
}
GameLoop = new IntegrationGameLoop(
DependencyCollection.Resolve<IGameTiming>(),
_fromInstanceWriter, _toInstanceReader);
server.OverrideMainLoop(GameLoop);
server.SetupMainLoop();
GameLoop.RunInit();
ResolveIoC(deps);
return server;
}
/// <summary>
/// Force a PVS update. This is mainly here to expose internal PVS methods to content benchmarks.
/// </summary>
public void PvsTick(ICommonSession[] players)
{
var pvs = EntMan.System<PvsSystem>();
pvs.SendGameStates(players);
Timing.CurTick += 1;
}
/// <summary>
/// Adds multiple dummy players to the server.
/// </summary>
public async Task<ICommonSession[]> AddDummySessions(int count)
{
var sessions = new ICommonSession[count];
for (var i = 0; i < sessions.Length; i++)
{
sessions[i] = await AddDummySession();
}
return sessions;
}
/// <summary>
/// Adds a dummy player to the server.
/// </summary>
public async Task<ICommonSession> AddDummySession(string? userName = null)
{
userName ??= $"integration_dummy_{DummyUsers.Count}";
Log.Info($"Adding dummy session {userName}");
if (!_dummyUsers.TryGetValue(userName, out var userId))
_dummyUsers[userName] = userId = new(Guid.NewGuid());
var man = (Robust.Server.Player.PlayerManager) PlayerMan;
var session = man.AddDummySession(userId, userName);
_dummySessions.Add(userId, session);
session.ConnectedTime = DateTime.UtcNow;
await WaitPost(() => man.SetStatus(session, SessionStatus.Connected));
return session;
}
/// <summary>
/// Removes a dummy player from the server.
/// </summary>
public async Task RemoveDummySession(ICommonSession session, bool removeUser = false)
{
Log.Info($"Removing dummy session {session.Name}");
_dummySessions.Remove(session.UserId);
var man = (Robust.Server.Player.PlayerManager) PlayerMan;
await WaitPost(() => man.EndSession(session.UserId));
if (removeUser)
_dummyUsers.Remove(session.Name);
}
/// <summary>
/// Removes all dummy players from the server.
/// </summary>
public async Task RemoveAllDummySessions()
{
foreach (var session in _dummySessions.Values)
{
await RemoveDummySession(session);
}
}
public override Task Cleanup() => RemoveAllDummySessions();
private Dictionary<string, NetUserId> _dummyUsers = new();
private Dictionary<NetUserId, ICommonSession> _dummySessions = new();
public IReadOnlyDictionary<string, NetUserId> DummyUsers => _dummyUsers;
public IReadOnlyDictionary<NetUserId, ICommonSession> DummySessions => _dummySessions;
}
public sealed class ClientIntegrationInstance : IntegrationInstance, IClientIntegrationInstance
{
public ICommonSession? Session => PlayerMan.LocalSession;
public NetUserId? User => Session?.UserId;
public EntityUid? AttachedEntity => Session?.AttachedEntity;
public ClientIntegrationInstance(ClientIntegrationOptions? options) : base(options)
{
ClientOptions = options;
DependencyCollection = new DependencyCollection();
if (options?.Asynchronous != false)
{
InstanceThread = new Thread(ThreadMain)
{
Name = "Client Instance Thread",
IsBackground = true
};
InstanceThread.Start();
}
else
{
Init();
}
}
public override IntegrationOptions? Options
{
get => ClientOptions;
internal set => ClientOptions = (ClientIntegrationOptions?) value;
}
public ClientIntegrationOptions? ClientOptions { get; internal set; }
public ClientIntegrationOptions? PreviousOptions { get; internal set; }
/// <summary>
/// Wire up the server to connect to when <see cref="IClientNetManager.ClientConnect"/> gets called.
/// </summary>
public void SetConnectTarget(IServerIntegrationInstance server)
{
var clientNetManager = ResolveDependency<IntegrationNetManager>();
var serverNetManager = server.Resolve<IntegrationNetManager>();
if (!serverNetManager.IsRunning)
{
throw new InvalidOperationException("Server net manager is not running!");
}
clientNetManager.NextConnectChannel = serverNetManager.MessageChannelWriter;
}
public async Task Connect(IServerIntegrationInstance target)
{
await WaitIdleAsync();
await target.WaitIdleAsync();
SetConnectTarget(target);
await WaitPost(() => ((IClientNetManager) NetMan).ClientConnect(null!, 0, null!));
}
public async Task CheckSandboxed(Assembly assembly)
{
await WaitIdleAsync();
await WaitAssertion(() =>
{
var modLoader = new ModLoader();
IoCManager.InjectDependencies(modLoader);
var cast = (IPostInjectInit) modLoader;
cast.PostInject();
modLoader.SetEnableSandboxing(true);
modLoader.LoadGameAssembly(assembly.Location);
});
}
private void ThreadMain()
{
try
{
var client = Init();
GameLoop.Run();
client.CleanupGameThread();
client.CleanupWindowThread();
}
catch (Exception e)
{
_fromInstanceWriter.TryWrite(new ShutDownMessage(e));
return;
}
_fromInstanceWriter.TryWrite(new ShutDownMessage(null));
}
private GameController Init()
{
var deps = DependencyCollection;
IoCManager.InitThread(deps, replaceExisting: true);
ClientIoC.RegisterIoC(GameController.DisplayMode.Headless, deps);
deps.Register<INetManager, IntegrationNetManager>(true);
deps.Register<IClientNetManager, IntegrationNetManager>(true);
deps.Register<IClientGameTiming, ClientGameTiming>(true);
deps.Register<IntegrationNetManager, IntegrationNetManager>(true);
deps.Register<IModLoader, TestingModLoader>(true);
deps.Register<IModLoaderInternal, TestingModLoader>(true);
deps.Register<TestingModLoader, TestingModLoader>(true);
deps.Register<IRobustMappedStringSerializer, IntegrationMappedStringSerializer>(true);
deps.Register<IClientConsoleHost, TestingClientConsoleHost>(true);
deps.Register<IConsoleHost, TestingClientConsoleHost>(true);
deps.Register<IConsoleHostInternal, TestingClientConsoleHost>(true);
deps.Register<IXamlProxyManager, XamlProxyManagerStub>(true);
deps.Register<IXamlHotReloadManager, XamlHotReloadManagerStub>(true);
Options?.InitIoC?.Invoke();
deps.BuildGraph();
GameController.RegisterReflection(deps);
if (Options?.LoadTestAssembly != false && Options?.TestAssembly != null)
deps.Resolve<IReflectionManager>().LoadAssemblies(Options.TestAssembly);
var client = DependencyCollection.Resolve<GameController>();
var clientOptions = ClientOptions?.Options ?? new GameControllerOptions()
{
LoadContentResources = false,
LoadConfigAndUserData = false,
};
// Autoregister components if options are null or we're NOT starting from content, as in that case
// components will get auto-registered later. But either way, we will still invoke
// BeforeRegisterComponents here.
Options?.BeforeRegisterComponents?.Invoke();
if (!Options?.ContentStart ?? true)
{
var componentFactory = deps.Resolve<IComponentFactory>();
componentFactory.DoAutoRegistrations();
componentFactory.GenerateNetIds();
}
if (Options?.ContentAssemblies != null)
{
deps.Resolve<TestingModLoader>().Assemblies = Options.ContentAssemblies;
}
var cfg = deps.Resolve<IConfigurationManagerInternal>();
cfg.LoadCVarsFromAssembly(typeof(RobustIntegrationTest).Assembly);
cfg.LoadCVarsFromAssembly(typeof(RTCVars).Assembly);
if (Options != null)
{
Options.BeforeStart?.Invoke();
cfg.OverrideConVars(Options.CVarOverrides.Select(p => (p.Key, p.Value)));
LoadExtraPrototypes(deps, Options);
}
cfg.OverrideConVars(new[]
{
(CVars.NetPredictLagBias.Name, "0"),
// Connecting to Discord is a massive waste of time.
// Basically just makes the CI logs a mess.
(CVars.DiscordEnabled.Name, "false"),
// Avoid preloading textures.
(CVars.ResTexturePreloadingEnabled.Name, "false"),
(CVars.SysGCCollectStart.Name, "false"),
(RTCVars.FailureLogLevel.Name, (Options?.FailureLogLevel ?? RTCVars.FailureLogLevel.DefaultValue).ToString()),
(CVars.ResPrototypeReloadWatch.Name, "false"),
(CVars.ResCheckBadFileExtensions.Name, "false")
});
cfg.SetVirtualConfig();
GameLoop = new IntegrationGameLoop(DependencyCollection.Resolve<IGameTiming>(),
_fromInstanceWriter, _toInstanceReader);
client.OverrideMainLoop(GameLoop);
client.ContentStart = Options?.ContentStart ?? false;
client.StartupSystemSplash(
clientOptions,
Options?.OverrideLogHandler ?? (() => new TestLogHandler(cfg, "CLIENT", _testOut)),
globalExceptionLog: false);
client.StartupContinue(GameController.DisplayMode.Headless);
GameLoop.RunInit();
ResolveIoC(deps);
// Offset client generated Uids.
// Not that we have client-server uid separation, there might be bugs where tests might accidentally
// use server side uids on the client and vice versa. This can sometimes accidentally work if the
// entities get created in the same order. For that reason we arbitrarily increment the queued Uid by
// some arbitrary quantity.
/* TODO: End my suffering and fix this because entmanager hasn't started up yet.
for (var i = 0; i < 10; i++)
{
EntMan.SpawnEntity(null, MapCoordinates.Nullspace);
}
*/
return client;
}
/// <summary>
/// Directly pass a bound key event to a control.
/// </summary>
public async Task DoGuiEvent(Control control, GUIBoundKeyEventArgs args)
{
await WaitPost(() =>
{
if (args.State == BoundKeyState.Down)
control.KeyBindDown(args);
else
control.KeyBindUp(args);
});
}
}
// 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<object> _channelWriter;
private readonly ChannelReader<object> _channelReader;
#pragma warning disable 67
public event EventHandler<FrameEventArgs>? Input;
public event EventHandler<FrameEventArgs>? Tick;
public event EventHandler<FrameEventArgs>? Update;
public event EventHandler<FrameEventArgs>? Render;
#pragma warning restore 67
public bool SingleStep { get; set; }
public bool Running { get; set; }
public int MaxQueuedTicks { get; set; }
public TimeSpan LimitMinFrameTime { get; set; }
public SleepMode SleepMode { get; set; }
public IntegrationGameLoop(IGameTiming gameTiming, ChannelWriter<object> channelWriter,
ChannelReader<object> channelReader)
{
_gameTiming = gameTiming;
_channelWriter = channelWriter;
_channelReader = channelReader;
}
public void Run()
{
// Main run method is only used when running from asynchronous thread.
// Ack tick message 1 is implied as "init done"
_channelWriter.TryWrite(new AckTicksMessage(1));
while (Running)
{
var readerNotDone = _channelReader.WaitToReadAsync().AsTask().GetAwaiter().GetResult();
if (!readerNotDone)
{
Running = false;
return;
}
SingleThreadRunUntilEmpty();
}
}
public void RunInit()
{
Running = true;
_gameTiming.InSimulation = true;
}
public void SingleThreadRunUntilEmpty()
{
while (Running && _channelReader.TryRead(out var message))
{
switch (message)
{
case RunTicksMessage msg:
_gameTiming.InSimulation = true;
var simFrameEvent = new FrameEventArgs((float) _gameTiming.TickPeriod.TotalSeconds);
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;
}
}
}
}
[Virtual]
public class ServerIntegrationOptions : IntegrationOptions
{
public virtual ServerOptions Options { get; set; } = new()
{
LoadConfigAndUserData = false,
LoadContentResources = false,
};
}
[Virtual]
public class ClientIntegrationOptions : IntegrationOptions
{
public virtual GameControllerOptions Options { get; set; } = new()
{
LoadContentResources = false,
LoadConfigAndUserData = false,
};
}
public abstract class IntegrationOptions
{
public Action? InitIoC { get; set; }
public Action? BeforeRegisterComponents { get; set; }
public Action? BeforeStart { get; set; }
public Assembly[]? ContentAssemblies { get; set; }
public bool LoadTestAssembly { get; set; } = true;
public Assembly? TestAssembly { get; set; }
/// <summary>
/// String containing extra prototypes to load. Contents of the string are treated like a yaml file in the
/// resources folder.
/// </summary>
public string? ExtraPrototypes { get; set; }
/// <summary>
/// List of strings containing extra prototypes to load. Contents of the strings are treated like yaml files
/// in the resources folder.
/// </summary>
public List<string>? ExtraPrototypeList;
public LogLevel? FailureLogLevel { get; set; } = RTCVars.FailureLogLevel.DefaultValue;
public bool ContentStart { get; set; } = false;
public Dictionary<string, string> CVarOverrides { get; } = new();
public bool Asynchronous { get; set; } = true;
public bool? Pool { get; set; }
public Func<ILogHandler>? OverrideLogHandler { get; set; }
}
/// <summary>
/// Sent head -> instance to tell the instance to run a few simulation ticks.
/// </summary>
private sealed class RunTicksMessage
{
public RunTicksMessage(int ticks, int messageId)
{
Ticks = ticks;
MessageId = messageId;
}
public int Ticks { get; }
public int MessageId { get; }
}
/// <summary>
/// Sent head -> instance to tell the instance to shut down cleanly.
/// </summary>
private sealed class StopMessage
{
}
/// <summary>
/// Sent instance -> head to confirm finishing of ticks message.
/// </summary>
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;
}
}
/// <summary>
/// Sent instance -> head when instance shuts down for whatever reason.
/// </summary>
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;
}
}
}
}