test: add integration tests xunit plugin/runner (#928)

This commit is contained in:
Michael Wilson
2025-07-14 14:54:35 +10:00
committed by GitHub
parent f08b5e8439
commit 26a8f641c8
12 changed files with 384 additions and 2 deletions

14
.vscode/tasks.json vendored
View File

@@ -6,10 +6,11 @@
{ {
"label": "sync-linux", "label": "sync-linux",
"type": "shell", "type": "shell",
"command": "lftp -c \"open -u $LINUX_SERVER_SFTP_USERNAME,$LINUX_SERVER_SFTP_PASSWORD $LINUX_SERVER_SFTP_HOST; mirror -R ${workspaceFolder}/build/addons /game/csgo/addons; mirror -R ${workspaceFolder}/managed/CounterStrikeSharp.API/bin/Release/net8.0/ /game/csgo/addons/counterstrikesharp/api\"", "command": "lftp -c \"open -u $LINUX_SERVER_SFTP_USERNAME,$LINUX_SERVER_SFTP_PASSWORD $LINUX_SERVER_SFTP_HOST; mirror -R ${workspaceFolder}/build/addons /game/csgo/addons; mirror -R ${workspaceFolder}/managed/CounterStrikeSharp.API/bin/Release/net8.0/ /game/csgo/addons/counterstrikesharp/api; mirror -R ${workspaceFolder}/managed/CounterStrikeSharp.Tests.Native/bin/Debug/net8.0/ /game/csgo/addons/counterstrikesharp/plugins/NativeTestsPlugin\"",
"dependsOn": [ "dependsOn": [
"build", "build",
"build-api" "build-api",
"build-test-plugin"
], ],
"problemMatcher": [] "problemMatcher": []
}, },
@@ -28,6 +29,15 @@
"cwd": "${workspaceFolder}/managed/CounterStrikeSharp.API" "cwd": "${workspaceFolder}/managed/CounterStrikeSharp.API"
} }
}, },
{
"label": "build-test-plugin",
"type": "shell",
"group": "build",
"command": "dotnet build -c Debug",
"options": {
"cwd": "${workspaceFolder}/managed/CounterStrikeSharp.Tests.Native"
}
},
{ {
"label": "generate-schema", "label": "generate-schema",
"type": "shell", "type": "shell",

View File

@@ -0,0 +1,46 @@
using System;
using System.Threading.Tasks;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Moq;
using Xunit;
namespace NativeTestsPlugin;
public class CommandTests
{
[Fact]
public async Task AddCommandHandler()
{
var mock = new Mock<Action>();
var methodCallback = FunctionReference.Create(() =>
{
mock.Object.Invoke();
});
NativeAPI.AddCommand("test_native", "description", true, (int)ConCommandFlags.FCVAR_LINKED_CONCOMMAND, methodCallback);
NativeAPI.IssueServerCommand("test_native");
await WaitOneFrame();
mock.Verify(s => s(), Times.Once);
NativeAPI.RemoveCommand("test_native", methodCallback);
NativeAPI.IssueServerCommand("test_native");
await WaitOneFrame();
mock.Verify(s => s(), Times.Once);
}
[Fact]
public async Task IssueServerCommand()
{
bool called = false;
NativeAPI.AddCommandListener("say", FunctionReference.Create(() =>
{
called = true;
}), true);
NativeAPI.IssueServerCommand("say Hello, world!");
await WaitOneFrame();
Assert.True(called, "The 'say' command handler was not called.");
}
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Spectre.Console;
using Xunit;
using Xunit.Abstractions;
namespace NativeTestsPlugin;
public class ConsoleTestReporterSink : LongLivedMarshalByRefObject, IMessageSink, IDisposable
{
public TaskCompletionSource<bool> Finished { get; } = new();
private int _passed = 0;
private int _failed = 0;
private int _skipped = 0;
private readonly object _lock = new();
public bool OnMessage(IMessageSinkMessage message)
{
lock (_lock)
{
switch (message)
{
// A test has passed
case ITestPassed passed:
Interlocked.Increment(ref _passed);
AnsiConsole.MarkupLineInterpolated($"[underline green][[PASS]][/] [green]{passed.Test.DisplayName}[/]");
break;
// A test has failed
case ITestFailed failed:
Interlocked.Increment(ref _failed);
AnsiConsole.MarkupLineInterpolated($"[underline red][[FAIL]][/] [red]{failed.Test.DisplayName}[/]");
AnsiConsole.WriteLine($"\tReason: {failed.ExceptionTypes[0]} - {failed.Messages[0]}");
AnsiConsole.WriteLine(IndentStackTrace(failed.StackTraces[0] ?? "No stack trace available."));
break;
// A test was skipped (e.g., using [Fact(Skip = "...")])
case ITestSkipped skipped:
Interlocked.Increment(ref _skipped);
AnsiConsole.MarkupLineInterpolated($"[underline yellow][[SKIP]][/] [yellow]{skipped.Test.DisplayName}[/]");
AnsiConsole.MarkupLineInterpolated($"[yellow]\tReason: {skipped.Reason}[/]");
break;
// This message indicates the entire test run for the assembly is complete.
case ITestAssemblyFinished:
// We signal the main thread that it can stop waiting now.
Finished.SetResult(true);
break;
}
}
return true;
}
public string GetSummary()
{
return $"Summary: {_passed} Passed, {_failed} Failed, {_skipped} Skipped.";
}
private static string IndentStackTrace(string stackTrace)
{
var builder = new StringBuilder();
var lines = stackTrace.Split(new[] { Environment.NewLine }, StringSplitOptions.None);
foreach (var line in lines)
{
builder.AppendLine($" {line}");
}
return builder.ToString();
}
public void Dispose()
{
Finished.TrySetResult(true);
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,28 @@
using System.Linq;
using System.Threading.Tasks;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Xunit;
namespace NativeTestsPlugin;
public class EntityTests
{
[Fact]
public void FindEntityAndAccessSchemaMembers()
{
var world = Utilities.FindAllEntitiesByDesignerName<CWorld>("worldent").FirstOrDefault();
Assert.NotNull(world);
Assert.Equal("worldent", world.DesignerName);
Assert.Equal((uint)0, world.Index);
Assert.Multiple(() =>
{
Assert.Equal(0, world.AbsOrigin.X);
Assert.Equal(0, world.AbsOrigin.Y);
Assert.Equal(0, world.AbsOrigin.Z);
});
}
}

View File

@@ -0,0 +1,39 @@
using System.Threading.Tasks;
using CounterStrikeSharp.API.Core;
using Moq;
using Xunit;
namespace NativeTestsPlugin;
public class GameEventTests
{
[Fact]
public async Task CanRegisterAndDeregisterEventHandlers()
{
int callCount = 0;
var callback = FunctionReference.Create((EventPlayerConnect @event) =>
{
Assert.NotNull(@event);
Assert.NotEmpty(@event.Name);
Assert.True(@event.Bot);
callCount++;
});
NativeAPI.HookEvent("player_connect", callback, true);
// Test hooking
NativeAPI.IssueServerCommand("bot_kick");
NativeAPI.IssueServerCommand("bot_add");
await WaitOneFrame();
Assert.Equal(1, callCount);
NativeAPI.UnhookEvent("player_connect", callback, true);
// Test unhooking
NativeAPI.IssueServerCommand("bot_kick");
NativeAPI.IssueServerCommand("bot_add");
await WaitOneFrame();
Assert.Equal(1, callCount);
}
}

View File

@@ -0,0 +1,2 @@
global using static TestUtils;
global using System;

View File

@@ -0,0 +1,36 @@
using System.Threading.Tasks;
using CounterStrikeSharp.API.Core;
using Xunit;
public class ListenerTests
{
[Fact]
public async Task CanRegisterAndDeregisterListeners()
{
int callCount = 0;
var callback = FunctionReference.Create((int playerSlot, string name, string ipAddress) =>
{
Assert.NotNull(ipAddress);
Assert.NotEmpty(name);
Assert.Equal("127.0.0.1", ipAddress);
callCount++;
});
NativeAPI.AddListener("OnClientConnect", callback);
// Test hooking
NativeAPI.IssueServerCommand("bot_kick");
NativeAPI.IssueServerCommand("bot_add");
await WaitOneFrame();
Assert.Equal(1, callCount);
NativeAPI.RemoveListener("OnClientConnect", callback);
// Test unhooking
NativeAPI.IssueServerCommand("bot_kick");
NativeAPI.IssueServerCommand("bot_add");
await WaitOneFrame();
Assert.Equal(1, callCount);
}
}

View File

@@ -0,0 +1,100 @@
/*
* This file is part of CounterStrikeSharp.
* CounterStrikeSharp is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* CounterStrikeSharp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with CounterStrikeSharp. If not, see <https://www.gnu.org/licenses/>. *
*/
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Xunit;
namespace NativeTestsPlugin
{
public class NativeTestsPlugin : BasePlugin
{
public override string ModuleName => "Native Tests";
public override string ModuleVersion => "v1.0.0";
public override string ModuleAuthor => "Roflmuffin";
public override string ModuleDescription => "A an automated test plugin.";
private int gameThreadId;
public override void Load(bool hotReload)
{
gameThreadId = Thread.CurrentThread.ManagedThreadId;
// Loading blocks the game thread, so we use NextFrame to run our tests asynchronously.
Server.NextFrame(() => RunTests());
}
async Task RunTests()
{
Console.WriteLine("*****************************************************************");
Console.WriteLine($"[{ModuleName}] Starting xUnit test run...");
Console.WriteLine("*****************************************************************");
try
{
using var reporter = new ConsoleTestReporterSink();
var project = new XunitProject();
using var controller = new XunitFrontController(AppDomainSupport.IfAvailable, this.ModulePath);
var executionOptions = TestFrameworkOptions.ForExecution();
executionOptions.SetDisableParallelization(true);
executionOptions.SetMaxParallelThreads(1);
executionOptions.SetSynchronousMessageReporting(true);
SynchronizationContext.SetSynchronizationContext(new SourceSynchronizationContext(gameThreadId));
controller.RunAll(reporter, TestFrameworkOptions.ForDiscovery(), executionOptions);
await reporter.Finished.Task;
Console.WriteLine("*****************************************************************");
Console.WriteLine($"[{ModuleName}] Test run finished.");
Console.WriteLine(reporter.GetSummary());
Console.WriteLine("*****************************************************************");
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[{ModuleName}] A critical error occurred during the test run setup: {ex.Message}");
Console.WriteLine(ex.StackTrace);
Console.ResetColor();
}
}
}
public class SourceSynchronizationContext : SynchronizationContext
{
private readonly int _mainThreadId;
public SourceSynchronizationContext(int mainThreadId)
{
_mainThreadId = mainThreadId;
}
public override void Post(SendOrPostCallback d, object state)
{
Server.NextWorldUpdate(() => d(state));
}
public override SynchronizationContext CreateCopy()
{
return this;
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Platforms>AnyCPU;x86</Platforms>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CounterStrikeSharp.API\CounterStrikeSharp.API.csproj">
<Private>false</Private>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="moq" Version="4.20.72" />
<PackageReference Include="Spectre.Console" Version="0.50.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.utility" Version="2.9.3" />
<PackageReference Include="xunit.extensibility.core" Version="2.9.3" />
<PackageReference Include="xunit.assert" Version="2.9.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
# Native Tests
This plugin is intended to be ran inside a running CS2 server running a version of CS# and runs tests against the exposed `NativeAPI` methods.

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using CounterStrikeSharp.API;
public static class TestUtils
{
public static async Task WaitOneFrame()
{
await Server.RunOnTickAsync(Server.TickCount + 2, () => { }).ConfigureAwait(false);
}
}

View File

@@ -47,6 +47,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WithUserMessages", "..\exam
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WithCheckTransmit", "..\examples\WithCheckTransmit\WithCheckTransmit.csproj", "{854B06B5-0E7B-438A-BA51-3F299557F884}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WithCheckTransmit", "..\examples\WithCheckTransmit\WithCheckTransmit.csproj", "{854B06B5-0E7B-438A-BA51-3F299557F884}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NativeTestsPlugin", "CounterStrikeSharp.Tests.Native\NativeTestsPlugin.csproj", "{317D3A98-D5C6-40BC-9234-CDAFC033ED0F}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -137,6 +139,10 @@ Global
{854B06B5-0E7B-438A-BA51-3F299557F884}.Debug|Any CPU.Build.0 = Debug|Any CPU {854B06B5-0E7B-438A-BA51-3F299557F884}.Debug|Any CPU.Build.0 = Debug|Any CPU
{854B06B5-0E7B-438A-BA51-3F299557F884}.Release|Any CPU.ActiveCfg = Release|Any CPU {854B06B5-0E7B-438A-BA51-3F299557F884}.Release|Any CPU.ActiveCfg = Release|Any CPU
{854B06B5-0E7B-438A-BA51-3F299557F884}.Release|Any CPU.Build.0 = Release|Any CPU {854B06B5-0E7B-438A-BA51-3F299557F884}.Release|Any CPU.Build.0 = Release|Any CPU
{317D3A98-D5C6-40BC-9234-CDAFC033ED0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{317D3A98-D5C6-40BC-9234-CDAFC033ED0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{317D3A98-D5C6-40BC-9234-CDAFC033ED0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{317D3A98-D5C6-40BC-9234-CDAFC033ED0F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE