Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace HSchool.AppHost.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Boots the AppHost once for the whole suite — starting it per test costs about ten
|
||||
/// seconds each. The client resource is skipped (<c>HSchool:Headless</c>), so the tests
|
||||
/// need no Node install.
|
||||
/// </summary>
|
||||
public sealed class AppHostFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(120);
|
||||
|
||||
private DistributedApplication? _app;
|
||||
|
||||
public DistributedApplication App =>
|
||||
_app ?? throw new InvalidOperationException("The AppHost has not been started.");
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var appHost = await DistributedApplicationTestingBuilder
|
||||
.CreateAsync<Projects.HSchool_AppHost>(["--HSchool:Headless=true"], CancellationToken.None);
|
||||
|
||||
appHost.Services.AddLogging(logging =>
|
||||
{
|
||||
logging.SetMinimumLevel(LogLevel.Warning);
|
||||
logging.AddFilter("Aspire.", LogLevel.Warning);
|
||||
});
|
||||
|
||||
_app = await appHost.BuildAsync().WaitAsync(StartupTimeout);
|
||||
await _app.StartAsync().WaitAsync(StartupTimeout);
|
||||
await _app.ResourceNotifications
|
||||
.WaitForResourceHealthyAsync("server", CancellationToken.None)
|
||||
.WaitAsync(StartupTimeout);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Groups every integration test around the single AppHost instance.</summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class AppHostCollection : ICollectionFixture<AppHostFixture>
|
||||
{
|
||||
public const string Name = "apphost";
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.AppHost.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to the running server exactly the way the browser client does: binary frames over
|
||||
/// a WebSocket, plus the HTTP endpoints the dashboard and probes use.
|
||||
/// </summary>
|
||||
[Collection(AppHostCollection.Name)]
|
||||
public class GameServerIntegrationTests(AppHostFixture fixture)
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private DistributedApplication App => fixture.App;
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_ReportsHealthy()
|
||||
{
|
||||
using var client = App.CreateHttpClient("server");
|
||||
|
||||
using var response = await client.GetAsync("/health", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusEndpoint_ReportsARunningLoop()
|
||||
{
|
||||
using var client = App.CreateHttpClient("server");
|
||||
|
||||
using var response = await client.GetAsync("/api/status", TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var status = JsonSerializer.Deserialize<StatusResponse>(
|
||||
await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
|
||||
JsonSerializerOptions.Web);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(20, status.TickRate);
|
||||
Assert.True(status.WorldWidth > 0);
|
||||
|
||||
// The loop runs on its own thread; give it a moment to produce a tick.
|
||||
await WaitUntilAsync(
|
||||
async () => (await GetStatusAsync(client)).Tick > 0,
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_AnswersWithAWelcomeFrame()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
|
||||
Assert.Equal(20, welcome.TickRate);
|
||||
Assert.True(welcome.PlayerEntityId > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshots_ArriveAndIncludeTheJoinedPlayer()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
|
||||
Assert.Contains(entities, entity => entity.Kind == EntityKind.Obstacle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Input_MovesThePlayerOnTheServer()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
var first = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
var startX = first.Single(entity => entity.Id == welcome.PlayerEntityId).X;
|
||||
|
||||
// Hold "right" for a few ticks, draining snapshots so the socket never backs up.
|
||||
var sequence = 0u;
|
||||
var lastX = startX;
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WriteInput(buffer, new ClientInputMessage(++sequence, InputButtons.Right)));
|
||||
|
||||
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
lastX = entities.Single(entity => entity.Id == welcome.PlayerEntityId).X;
|
||||
}
|
||||
|
||||
Assert.True(lastX > startX, $"Player did not move right: {startX} -> {lastX}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_IsAnsweredWithTheSameTimestamp()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
await ReceiveWelcomeAsync(socket);
|
||||
|
||||
const long ClientTime = 1_700_000_000_123;
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WritePing(buffer, new ClientPingMessage(ClientTime)));
|
||||
|
||||
var pong = await ReceiveUntilAsync(socket, MessageType.ServerPong);
|
||||
|
||||
Assert.Equal(ClientTime, ProtocolCodec.ReadPong(pong).ClientTimeMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VersionMismatch_IsRejected()
|
||||
{
|
||||
using var socket = await ConnectRawAsync();
|
||||
|
||||
await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
|
||||
buffer,
|
||||
new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), "stale-client")));
|
||||
|
||||
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
||||
var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
|
||||
Assert.Equal(WebSocketCloseStatus.ProtocolError, socket.CloseStatus);
|
||||
}
|
||||
|
||||
private async Task<ClientWebSocket> ConnectAsync(string playerName = "integration-test")
|
||||
{
|
||||
var socket = await ConnectRawAsync();
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, playerName)));
|
||||
return socket;
|
||||
}
|
||||
|
||||
private async Task<ClientWebSocket> ConnectRawAsync()
|
||||
{
|
||||
var http = App.GetEndpoint("server", "http");
|
||||
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
|
||||
|
||||
var socket = new ClientWebSocket();
|
||||
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
|
||||
return socket;
|
||||
}
|
||||
|
||||
private static async Task SendAsync(WebSocket socket, Func<byte[], int> write)
|
||||
{
|
||||
var buffer = new byte[64];
|
||||
var length = write(buffer);
|
||||
|
||||
await socket.SendAsync(
|
||||
buffer.AsMemory(0, length),
|
||||
WebSocketMessageType.Binary,
|
||||
endOfMessage: true,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<ServerWelcomeMessage> ReceiveWelcomeAsync(WebSocket socket) =>
|
||||
ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
|
||||
|
||||
private static async Task<EntitySnapshot[]> ReceiveSnapshotAsync(WebSocket socket)
|
||||
{
|
||||
var frame = await ReceiveUntilAsync(socket, MessageType.ServerSnapshot);
|
||||
|
||||
var entities = new EntitySnapshot[ushort.MaxValue];
|
||||
var count = ProtocolCodec.ReadSnapshot(frame, entities, out _);
|
||||
|
||||
return entities[..count];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads snapshots until the given entity shows up. The very first snapshot after a join can
|
||||
/// still describe the tick before the spawn was applied.
|
||||
/// </summary>
|
||||
private static async Task<EntitySnapshot[]> ReceiveSnapshotWithAsync(WebSocket socket, uint entityId)
|
||||
{
|
||||
for (var attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
var entities = await ReceiveSnapshotAsync(socket);
|
||||
if (Array.Exists(entities, entity => entity.Id == entityId))
|
||||
{
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Entity {entityId} never appeared in a snapshot.");
|
||||
}
|
||||
|
||||
/// <summary>Reads frames until one of <paramref name="expected"/> shows up.</summary>
|
||||
private static async Task<byte[]> ReceiveUntilAsync(WebSocket socket, MessageType expected)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
timeout.CancelAfter(DefaultTimeout);
|
||||
|
||||
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(buffer, timeout.Token);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}.");
|
||||
}
|
||||
|
||||
var frame = buffer[..result.Count];
|
||||
if (ProtocolCodec.PeekMessageType(frame) == expected)
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StatusResponse> GetStatusAsync(HttpClient client)
|
||||
{
|
||||
var json = await client.GetStringAsync("/api/status", TestContext.Current.CancellationToken);
|
||||
return JsonSerializer.Deserialize<StatusResponse>(json, JsonSerializerOptions.Web)!;
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (await condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
Assert.Fail($"Condition was not met within {timeout}.");
|
||||
}
|
||||
|
||||
private sealed record StatusResponse(
|
||||
uint Tick,
|
||||
int TickRate,
|
||||
int Players,
|
||||
int Connections,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.AppHost.Tests</RootNamespace>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.Testing" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\HSchool.AppHost\HSchool.AppHost.csproj" />
|
||||
<ProjectReference Include="..\..\src\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System.Net" />
|
||||
<Using Include="Aspire.Hosting" />
|
||||
<Using Include="Aspire.Hosting.Testing" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user