Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0ec4d28
feat(launching): surface native launch failures and set the loader se…
bobtista Jul 29, 2026
d0bbc6b
test(launching): verify the real native client launches through GenHub
bobtista Jul 29, 2026
7083aa2
test(launching): serialize real-client launches around the engine ins…
bobtista Jul 29, 2026
ad273e7
feat(launching): reach retail archives via install-path environment i…
bobtista Jul 29, 2026
602fd2f
fix(launching): stop setting DYLD_LIBRARY_PATH and LD_LIBRARY_PATH
bobtista Jul 29, 2026
caf278a
feat(launching): validate retail archive roots before spawning the en…
bobtista Jul 29, 2026
b41e5ee
fix(launching): capture complete stderr when a launch fails
bobtista Jul 29, 2026
58429d5
chore: add stderr capture regression test and clear stack build warnings
bobtista Jul 29, 2026
8120e5e
chore(workspace): order static helper before instance members
bobtista Jul 29, 2026
3e40fa8
chore(workspace): remove trailing class separator
bobtista Jul 29, 2026
461dcdb
chore: clear native launch style warnings
bobtista Jul 29, 2026
08d5bac
chore(workspace): keep member order valid after the executable materi…
bobtista Jul 30, 2026
bdb0b19
fix(launching): scope archive-root validation to non-Windows and norm…
bobtista Jul 30, 2026
5ac6b3a
test(launching): scope the archive-root rejection tests to non-Windows
bobtista Jul 30, 2026
00a2bc6
fix(launching): scope archive validation to the launching game and re…
bobtista Jul 30, 2026
aeaa1c0
fix(launching): validate the base Generals root when launching Zero Hour
bobtista Jul 30, 2026
7e6f8ce
docs(launching): correct why an absent Generals root is tolerated and…
bobtista Jul 30, 2026
1da85e5
docs(launching): correct the engine-behaviour claim behind archive-ro…
bobtista Jul 30, 2026
69460ea
test(launching): validate the fixture override and recognise versione…
bobtista Jul 30, 2026
3eccb33
fix(launching): match retail archives case-insensitively when validat…
bobtista Jul 30, 2026
cf056e7
fix(launching): ground archive-root failure messages in the verified …
bobtista Jul 30, 2026
d1adeeb
chore(launching): reference launch constants in tests and fix stale n…
bobtista Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Reflection;
using GenHub.Features.GameProfiles.Infrastructure;
using Xunit;

namespace GenHub.Tests.Core.Features.GameProfiles;

/// <summary>
/// Tests for the bounded stderr capture used to explain a failed launch.
/// </summary>
/// <remarks>
/// The buffer is a private nested type; it is exercised through reflection rather than
/// being made public, because it is an implementation detail of process management and
/// its behaviour only matters through the diagnostics it produces.
/// </remarks>
public class BoundedErrorBufferTests
{
private static readonly Type BufferType =
typeof(GameProcessManager).GetNestedType("BoundedErrorBuffer", BindingFlags.NonPublic)!;

/// <summary>
/// The startup context is where the cause usually is, so the first lines must survive
/// even when far more output follows than the buffer retains.
/// </summary>
[Fact]
public void Append_RetainsBothTheHeadAndTheTail()
{
var buffer = CreateBuffer();

Append(buffer, "dyld: library not loaded");
for (var i = 0; i < 200; i++)
{
Append(buffer, $"noise line {i}");
}

Append(buffer, "Abort trap: 6");

var text = buffer.ToString()!;

Assert.Contains("dyld: library not loaded", text);
Assert.Contains("Abort trap: 6", text);
Assert.Contains("omitted", text);
}

/// <summary>
/// A single pathological line must not be retained in full.
/// </summary>
[Fact]
public void Append_TruncatesAnOverlongLine()
{
var buffer = CreateBuffer();

Append(buffer, new string('x', 10_000));

var text = buffer.ToString()!;

Assert.Contains("line truncated", text);
Assert.True(text.Length < 10_000, "The overlong line was retained in full.");
}

/// <summary>
/// A null line is the framework's end-of-stream signal, not content.
/// </summary>
[Fact]
public void Append_TreatsNullAsEndOfStreamRatherThanContent()
{
var buffer = CreateBuffer();

Assert.False(EndOfStreamReached(buffer));

Append(buffer, "something failed");
Append(buffer, null);

Assert.True(EndOfStreamReached(buffer));
Assert.Equal("something failed", buffer.ToString());
}

/// <summary>
/// Total retained output stays bounded regardless of how much arrives.
/// </summary>
[Fact]
public void Append_BoundsTotalRetainedOutput()
{
var buffer = CreateBuffer();

for (var i = 0; i < 5_000; i++)
{
Append(buffer, new string('y', 500));
}

Assert.True(
buffer.ToString()!.Length < 128 * 1024,
"Retained output grew beyond the cap.");
}

private static object CreateBuffer() => Activator.CreateInstance(BufferType, nonPublic: true)!;

private static void Append(object buffer, string? line) =>
BufferType.GetMethod("Append", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(buffer, [line]);

private static bool EndOfStreamReached(object buffer) =>
(bool)BufferType.GetProperty("EndOfStreamReached", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(buffer)!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Xunit;

namespace GenHub.Tests.Core.Features.GameProfiles;

/// <summary>
/// Serialises the tests that launch a real native game client.
/// <para>
/// The engine enforces a single running instance, so two of these in parallel produce a
/// spurious failure: the second launch is refused by the first. That is engine behaviour
/// rather than a test defect, and it has a product consequence — GenHub cannot run two
/// native profiles simultaneously.
/// </para>
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public class NativeClientLaunchCollection
{
/// <summary>The xUnit collection name.</summary>
public const string Name = "Native client launch";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using GenHub.Core.Models.Launching;
using GenHub.Core.Models.Manifest;
using GenHub.Features.GameProfiles.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace GenHub.Tests.Core.Features.GameProfiles;

/// <summary>
/// End-to-end launch of a real native Zero Hour client through GenHub's own process
/// manager, rather than a stand-in script.
/// <para>
/// Everything else in the native-client work is verified against synthetic binaries.
/// This is the one test that answers the actual question: can GenHub start the real
/// engine, against real retail data, and have it remain running.
/// </para>
/// <para>
/// Skipped unless a native install is present, so CI and other machines stay green.
/// Point <c>GENHUB_NATIVE_CLIENT_DIR</c> at an install directory to run it; the default
/// is the deploy script's own default location.
/// </para>
/// </summary>
[Collection(NativeClientLaunchCollection.Name)]
public class NativeClientLaunchIntegrationTests
{
private const string EnvironmentOverride = "GENHUB_NATIVE_CLIENT_DIR";
private const string BinaryName = "generalszh";

/// <summary>How long the engine must stay up to count as a successful launch.</summary>
private static readonly TimeSpan LaunchSettleTime = TimeSpan.FromSeconds(12);

private readonly GameProcessManager _processManager = new(NullLogger<GameProcessManager>.Instance);

private static string? NativeClientDirectory
{
get
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return null;
}

var configured = Environment.GetEnvironmentVariable(EnvironmentOverride);
if (!string.IsNullOrWhiteSpace(configured))
{
return Directory.Exists(configured) ? configured : null;
}

var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var defaultDirectory = Path.Combine(home, "TheSuperHackers", "GeneralsZH");

return File.Exists(Path.Combine(defaultDirectory, BinaryName)) ? defaultDirectory : null;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// <summary>
/// Launches the engine with the install directory as the working directory, exactly
/// as a workspace launch would, and requires it to survive startup.
/// <para>
/// Windowed mode is requested so a test run cannot take over the display.
/// </para>
/// </summary>
/// <returns>A task representing the asynchronous test.</returns>
[Fact]
public async Task RealNativeClient_LaunchesThroughGameProcessManager()
{
var installDirectory = NativeClientDirectory;
if (installDirectory is null)
{
return;
}

var configuration = new GameLaunchConfiguration
{
ExecutablePath = Path.Combine(installDirectory, BinaryName),
WorkingDirectory = installDirectory,
Arguments = new() { ["-win"] = string.Empty },
};

var result = await _processManager.StartProcessAsync(configuration);

Assert.True(result.Success, $"Launch failed: {string.Join(" ", result.Errors)}");
Assert.NotNull(result.Data);

try
{
// StartProcessAsync only waits out the launcher-detection delay. Give the
// engine long enough to fail the way it fails for real: mounting archives and
// initialising the renderer, both of which happen after the process exists.
await Task.Delay(LaunchSettleTime);

var info = await _processManager.GetProcessInfoAsync(result.Data!.ProcessId);
const string failureMessage =
"The engine started and then exited during initialisation. That is the failure "
+ "mode this work exists to make visible; check the captured output.";

Assert.True(info.Success, failureMessage);
}
finally
{
await _processManager.TerminateProcessAsync(result.Data!.ProcessId);
}
}

/// <summary>
/// The working directory is load-bearing, not cosmetic. The engine discovers its
/// <c>.big</c> archives relative to the current directory, so launching from anywhere
/// else fails within about a second — it finds no archives, or worse, mounts unrelated
/// ones it happens to encounter.
/// <para>
/// This is why <c>WorkspaceStrategyBase</c> sets <c>WorkingDirectory</c> to the
/// workspace root, and why a native client cannot simply be launched in place.
/// </para>
/// </summary>
/// <returns>A task representing the asynchronous test.</returns>
[Fact]
public async Task RealNativeClient_RequiresItsInstallDirectoryAsWorkingDirectory()
{
var installDirectory = NativeClientDirectory;
if (installDirectory is null)
{
return;
}

var isolatedDirectory = Path.Combine(
Path.GetTempPath(),
$"genhub-wrong-cwd-{Guid.NewGuid():N}");
Directory.CreateDirectory(isolatedDirectory);

try
{
var configuration = new GameLaunchConfiguration
{
ExecutablePath = Path.Combine(installDirectory, BinaryName),
WorkingDirectory = isolatedDirectory,
Arguments = new() { ["-win"] = string.Empty },
};

var result = await _processManager.StartProcessAsync(configuration);

if (result.Success && result.Data is not null)
{
await Task.Delay(TimeSpan.FromSeconds(6));
var info = await _processManager.GetProcessInfoAsync(result.Data.ProcessId);
await _processManager.TerminateProcessAsync(result.Data.ProcessId);
const string failureMessage =
"The engine survived being launched from an unrelated working directory. If it "
+ "no longer resolves archives relative to the current directory, the workspace "
+ "model can be relaxed.";

Assert.False(info.Success, failureMessage);
return;
}

// The expected path: it dies during startup and the failure names the reason
// rather than reporting a bare exit code.
Assert.False(result.Success);
Assert.Contains("exited immediately", string.Join(" ", result.Errors), StringComparison.OrdinalIgnoreCase);
}
finally
{
try
{
Directory.Delete(isolatedDirectory, recursive: true);
}
catch (IOException)
{
// Cleanup failure is not a test failure.
}
}
}
}
Loading
Loading