feat(tooling): add standalone rendezvous test client (#25)
quality-gate / quality (push) Failing after 1m3s

This commit is contained in:
KyuubiYoru
2026-07-16 11:05:56 +02:00
parent 94aba8a3bb
commit 7e3be2cad1
16 changed files with 2721 additions and 12 deletions
@@ -0,0 +1,377 @@
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.TestClient;
internal enum TestClientMode
{
Host,
Browse,
Join,
}
internal sealed class TestClientOptions
{
internal TestClientMode Mode { get; init; }
internal Uri ServiceUri { get; init; } = new("http://127.0.0.1:5000/");
internal IPEndPoint Mediator { get; init; } = new(IPAddress.Loopback, 9050);
internal GameId GameId { get; init; } = new("space-game");
internal EnvironmentId EnvironmentId { get; init; } = new("development");
internal RegionId RegionId { get; init; } = new("local");
internal uint ProtocolVersion { get; init; } = 1;
internal string BuildVersion { get; init; } = "test-client";
internal string DisplayName { get; init; } = "Rendezvous diagnostic host";
internal string PublisherCredentialEnvironmentVariable { get; init; } =
"RENDEZVOUS_PUBLISHER_CREDENTIAL";
internal Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
internal NetworkEndpoint? DedicatedFallback { get; init; }
internal SessionListingId? ListingId { get; init; }
internal int LocalPort { get; init; }
internal int PageSize { get; init; } = 20;
internal TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(20);
internal TimeSpan? RunDuration { get; init; }
internal bool Script { get; init; }
internal bool Json { get; init; }
internal bool ExitAfterEcho { get; init; }
}
internal sealed class TestClientParseResult
{
private TestClientParseResult(TestClientOptions? options, string? error, bool showHelp)
{
Options = options;
Error = error;
ShowHelp = showHelp;
}
internal TestClientOptions? Options { get; }
internal string? Error { get; }
internal bool ShowHelp { get; }
internal bool Succeeded => Options is not null;
internal static TestClientParseResult Success(TestClientOptions options) => new(options, null, false);
internal static TestClientParseResult Failure(string error) => new(null, error, false);
internal static TestClientParseResult Help() => new(null, null, true);
}
internal static class TestClientOptionParser
{
internal const string Usage = """
Rendezvous diagnostic client
Usage:
rendezvous-test-client host [options]
rendezvous-test-client browse [options]
rendezvous-test-client join [options]
Common options:
--service URL HTTP(S) Rendezvous base URL
--mediator IP:PORT UDP mediator endpoint
--game ID Game scope (default: space-game)
--environment ID Environment scope (default: development)
--protocol NUMBER Exact gameplay protocol (default: 1)
--region ID Region filter/publication (default: local)
--timeout-seconds NUMBER Bounded startup/traversal stage, 1-300 (default: 20)
--port NUMBER Caller-owned gameplay UDP port; 0 chooses one
--page-size NUMBER Bounded browser page size, 1-100 (default: 20)
--script Never prompt; select the first compatible listing
--json Emit one versioned JSON event per line
--help Show this help
Host options:
--publisher-credential-env NAME Environment variable containing the credential
--display-name TEXT Public listing name
--build-version TEXT Public build version
--metadata KEY=VALUE Bounded public metadata; may be repeated
--fallback IP:PORT Optional policy-authorized dedicated fallback
--run-seconds NUMBER Stop after 1-86400 seconds
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
Join options:
--listing UUID Join an exact listing; otherwise browse/select
Credentials are accepted only through the named environment variable. They are never
accepted on the command line and are never written to human or JSON output.
""";
internal static TestClientParseResult Parse(string[] args)
{
if (args.Length == 0 || args.Length == 1 && IsHelp(args[0]))
{
return TestClientParseResult.Help();
}
if (args.Length > 64)
{
return TestClientParseResult.Failure("Too many command-line arguments.");
}
if (!TryMode(args[0], out TestClientMode mode))
{
return TestClientParseResult.Failure("The first argument must be host, browse, or join.");
}
Uri serviceUri = new("http://127.0.0.1:5000/");
IPEndPoint mediator = new(IPAddress.Loopback, 9050);
string game = "space-game";
string environment = "development";
string region = "local";
uint protocol = 1;
string buildVersion = "test-client";
string displayName = "Rendezvous diagnostic host";
string credentialEnvironmentVariable = "RENDEZVOUS_PUBLISHER_CREDENTIAL";
Dictionary<string, string> metadata = new(StringComparer.Ordinal);
NetworkEndpoint? dedicatedFallback = null;
SessionListingId? listingId = null;
int localPort = 0;
int pageSize = 20;
int timeoutSeconds = 20;
int? runSeconds = null;
bool script = false;
bool json = false;
bool exitAfterEcho = false;
HashSet<string> seen = new(StringComparer.Ordinal);
for (int index = 1; index < args.Length; index++)
{
string option = args[index];
if (IsHelp(option))
{
return TestClientParseResult.Help();
}
if (option is "--script" or "--json" or "--exit-after-echo")
{
if (!seen.Add(option))
{
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
}
script |= option == "--script";
json |= option == "--json";
exitAfterEcho |= option == "--exit-after-echo";
continue;
}
if (!option.StartsWith("--", StringComparison.Ordinal)
|| index + 1 >= args.Length)
{
return TestClientParseResult.Failure("Every option must use the form --name value.");
}
string value = args[++index];
if (value.Length is 0 or > 512)
{
return TestClientParseResult.Failure($"Option {option} has an invalid value length.");
}
if (option != "--metadata" && !seen.Add(option))
{
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
}
switch (option)
{
case "--service":
if (!TryServiceUri(value, out serviceUri))
{
return TestClientParseResult.Failure("The service URL must be absolute HTTP(S), credential-free, and query-free.");
}
break;
case "--mediator":
if (!IPEndPoint.TryParse(value, out IPEndPoint? parsedMediator)
|| parsedMediator.Port == 0)
{
return TestClientParseResult.Failure("The mediator must be an IP endpoint with a non-zero port.");
}
mediator = parsedMediator;
break;
case "--game":
game = value;
break;
case "--environment":
environment = value;
break;
case "--region":
region = value;
break;
case "--protocol":
if (!uint.TryParse(value, out protocol) || protocol == 0)
{
return TestClientParseResult.Failure("The protocol must be a positive integer.");
}
break;
case "--build-version":
buildVersion = value;
break;
case "--display-name":
displayName = value;
break;
case "--publisher-credential-env":
if (!IsEnvironmentVariableName(value))
{
return TestClientParseResult.Failure("The credential environment-variable name is invalid.");
}
credentialEnvironmentVariable = value;
break;
case "--metadata":
if (!TryMetadata(value, metadata))
{
return TestClientParseResult.Failure("Metadata must be a unique KEY=VALUE pair with a non-empty key.");
}
break;
case "--fallback":
if (!IPEndPoint.TryParse(value, out IPEndPoint? fallbackEndpoint)
|| fallbackEndpoint.Port == 0)
{
return TestClientParseResult.Failure("The fallback must be an IP endpoint with a non-zero port.");
}
dedicatedFallback = new NetworkEndpoint
{
AddressFamily = fallbackEndpoint.AddressFamily == AddressFamily.InterNetwork
? AddressFamilyKind.Ipv4
: AddressFamilyKind.Ipv6,
Address = fallbackEndpoint.Address.ToString(),
Port = fallbackEndpoint.Port,
};
break;
case "--listing":
if (!Guid.TryParse(value, out Guid parsedListing) || parsedListing == Guid.Empty)
{
return TestClientParseResult.Failure("The listing must be a non-empty UUID.");
}
listingId = new SessionListingId(parsedListing);
break;
case "--port":
if (!int.TryParse(value, out localPort) || localPort is < 0 or > 65_535)
{
return TestClientParseResult.Failure("The local UDP port must be between 0 and 65535.");
}
break;
case "--page-size":
if (!int.TryParse(value, out pageSize)
|| pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
{
return TestClientParseResult.Failure("The page size is outside the contract limit.");
}
break;
case "--timeout-seconds":
if (!int.TryParse(value, out timeoutSeconds) || timeoutSeconds is < 1 or > 300)
{
return TestClientParseResult.Failure("The timeout must be between 1 and 300 seconds.");
}
break;
case "--run-seconds":
if (!int.TryParse(value, out int parsedRunSeconds)
|| parsedRunSeconds is < 1 or > 86_400)
{
return TestClientParseResult.Failure("The host run duration must be between 1 and 86400 seconds.");
}
runSeconds = parsedRunSeconds;
break;
default:
return TestClientParseResult.Failure($"Unknown option {option}.");
}
}
if (!IsSlug(game, ContractLimits.GameIdMaxCharacters)
|| !IsSlug(environment, ContractLimits.EnvironmentIdMaxCharacters)
|| !IsSlug(region, ContractLimits.RegionIdMaxCharacters)
|| !ContractValidation.IsBuildVersionValid(buildVersion)
|| !ContractValidation.IsDisplayNameValid(displayName)
|| !ContractValidation.IsMetadataValid(metadata))
{
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
}
if (listingId.HasValue && mode != TestClientMode.Join
|| runSeconds.HasValue && mode != TestClientMode.Host
|| exitAfterEcho && mode != TestClientMode.Host
|| metadata.Count > 0 && mode != TestClientMode.Host
|| dedicatedFallback is not null && mode != TestClientMode.Host
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|| seen.Contains("--display-name") && mode != TestClientMode.Host
|| seen.Contains("--build-version") && mode != TestClientMode.Host)
{
return TestClientParseResult.Failure("One or more options do not apply to the selected mode.");
}
return TestClientParseResult.Success(new TestClientOptions
{
Mode = mode,
ServiceUri = serviceUri,
Mediator = mediator,
GameId = new(game),
EnvironmentId = new(environment),
RegionId = new(region),
ProtocolVersion = protocol,
BuildVersion = buildVersion,
DisplayName = displayName,
PublisherCredentialEnvironmentVariable = credentialEnvironmentVariable,
Metadata = metadata,
DedicatedFallback = dedicatedFallback,
ListingId = listingId,
LocalPort = localPort,
PageSize = pageSize,
OperationTimeout = TimeSpan.FromSeconds(timeoutSeconds),
RunDuration = runSeconds.HasValue
? TimeSpan.FromSeconds(runSeconds.Value)
: mode == TestClientMode.Host && script
? TimeSpan.FromSeconds(timeoutSeconds)
: null,
Script = script,
Json = json,
ExitAfterEcho = exitAfterEcho,
});
}
private static bool TryMode(string value, out TestClientMode mode) =>
Enum.TryParse(value, true, out mode) && Enum.IsDefined(mode);
private static bool IsHelp(string value) => value is "--help" or "-h" or "help";
private static bool TryServiceUri(string value, out Uri uri)
{
uri = null!;
if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)
|| parsed.Scheme is not ("http" or "https")
|| !string.IsNullOrEmpty(parsed.UserInfo)
|| !string.IsNullOrEmpty(parsed.Query)
|| !string.IsNullOrEmpty(parsed.Fragment))
{
return false;
}
UriBuilder builder = new(parsed) { Path = parsed.AbsolutePath.TrimEnd('/') + "/" };
uri = builder.Uri;
return true;
}
private static bool IsEnvironmentVariableName(string value)
{
if (value.Length is 0 or > 64 || !(char.IsLetter(value[0]) || value[0] == '_'))
{
return false;
}
return value.All(static character =>
char.IsAsciiLetterOrDigit(character) || character == '_');
}
private static bool TryMetadata(string value, Dictionary<string, string> metadata)
{
int separator = value.IndexOf('=');
if (separator is < 1 or > ContractLimits.MetadataKeyMaxBytes
|| metadata.Count >= ContractLimits.MetadataMaxKeys)
{
return false;
}
string key = value[..separator];
string metadataValue = value[(separator + 1)..];
return !string.IsNullOrWhiteSpace(key)
&& ContractValidation.IsUtf8LengthWithin(key, ContractLimits.MetadataKeyMaxBytes)
&& ContractValidation.IsUtf8LengthWithin(metadataValue, ContractLimits.MetadataValueMaxBytes)
&& metadata.TryAdd(key, metadataValue);
}
private static bool IsSlug(string value, int maximumCharacters) =>
value.Length is > 0
&& value.Length <= maximumCharacters
&& value[0] is >= 'a' and <= 'z'
&& value.All(static character => character is >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-');
}