diff --git a/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs b/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs new file mode 100644 index 00000000..0b9bbf11 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs @@ -0,0 +1,66 @@ +using System.IO; + +namespace GenHub.Core.Constants; + +/// +/// The contract between GenHub and a non-Windows engine build for locating retail archives. +/// +/// +/// A non-Windows engine reads InstallPath through GetStringFromRegistry, which +/// on these platforms consults these variables before anything else, then mounts +/// *.big from those roots in addition to the working directory. The names are +/// therefore an external contract: changing one silently stops the engine finding content, +/// with no error from either side. +/// +/// Windows resolves install paths from the registry and does not read these, so nothing is +/// set — and nothing should be validated against them — on that platform. +/// +/// +public static class RetailArchiveConstants +{ + /// Environment variable naming the Zero Hour retail directory. + public const string ZeroHourInstallPathVariable = "CNC_ZH_INSTALLPATH"; + + /// Environment variable naming the Generals retail directory. + public const string GeneralsInstallPathVariable = "CNC_GENERALS_INSTALLPATH"; + + /// + /// Search pattern for the archives the engine mounts from a retail root. + /// + /// + /// Used as an existence sentinel rather than matching a specific filename, which varies + /// by localisation and version. + /// + public const string ArchiveSearchPattern = "*.big"; + + /// + /// How is matched within a retail root. + /// + /// + /// Case-insensitive because retail data copied from a disc or a Windows machine is + /// frequently upper-cased, while the default glob is case-sensitive on Linux volumes and on + /// case-sensitive APFS. INIZH.BIG would otherwise read as no archives at all and + /// block a launch that would have worked — the opposite of what the check exists to do. + /// + /// is set back to false because + /// it defaults to true here, unlike the overload. Left at + /// the default it turns an unreadable root into "no archives found", reporting a permission + /// problem as missing content. + /// + /// + public static readonly EnumerationOptions ArchiveSearch = new() + { + MatchCasing = MatchCasing.CaseInsensitive, + RecurseSubdirectories = false, + IgnoreInaccessible = false, + }; + + /// + /// Every retail archive root variable, Zero Hour first. + /// + public static readonly string[] InstallPathVariables = + [ + ZeroHourInstallPathVariable, + GeneralsInstallPathVariable, + ]; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/BoundedErrorBufferTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/BoundedErrorBufferTests.cs new file mode 100644 index 00000000..74e36efc --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/BoundedErrorBufferTests.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using GenHub.Features.GameProfiles.Infrastructure; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Tests for the bounded stderr capture used to explain a failed launch. +/// +/// +/// 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. +/// +public class BoundedErrorBufferTests +{ + private static readonly Type BufferType = + typeof(GameProcessManager).GetNestedType("BoundedErrorBuffer", BindingFlags.NonPublic)!; + + /// + /// 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. + /// + [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); + } + + /// + /// A single pathological line must not be retained in full. + /// + [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."); + } + + /// + /// A null line is the framework's end-of-stream signal, not content. + /// + [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()); + } + + /// + /// Total retained output stays bounded regardless of how much arrives. + /// + [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)!; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixture.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixture.cs new file mode 100644 index 00000000..e22c3adf --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixture.cs @@ -0,0 +1,74 @@ +using System.Linq; +using System.Runtime.InteropServices; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Locates the local native client these integration tests launch. +/// +/// +/// Shared by every test that spawns the real engine, so discovery stays in one place: all +/// of them skip unless a client is present, and diverging on how it is found would make +/// some skip while others fail. +/// +public static class NativeClientFixture +{ + /// Environment variable overriding the discovered directory. + public const string EnvironmentOverride = "GENHUB_NATIVE_CLIENT_DIR"; + + /// The engine executable's filename. + public const string BinaryName = "generalszh"; + + /// + /// Gets the native client directory, or null when these tests should skip. + /// + public static string? Directory + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return null; + } + + var configured = Environment.GetEnvironmentVariable(EnvironmentOverride); + if (!string.IsNullOrWhiteSpace(configured)) + { + // Validated the same way as the discovered default below. A directory that + // exists but holds no engine binary is a misconfigured override, and these + // tests should skip rather than fail on it. + return File.Exists(Path.Combine(configured, BinaryName)) ? 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; + } + } + + /// + /// Determines whether a filename is one of the engine's dynamic libraries. + /// + /// + /// Covers macOS .dylib and both Linux forms: unversioned .so and versioned + /// .so.0 / .so.0.1.0, which are the common shape of a shipped Linux library + /// and which a plain .so suffix test misses. + /// + /// The filename to test. + /// true when the file is a dynamic library. + public static bool IsDynamicLibrary(string fileName) + { + if (fileName.EndsWith(".dylib", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".so", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Matched on ".so." rather than ".so" so an earlier coincidental ".so" — inside + // ".sound", say — cannot shadow the real suffix and end the search early. + var versioned = fileName.IndexOf(".so.", StringComparison.OrdinalIgnoreCase); + return versioned >= 0 + && fileName[(versioned + 4)..].All(c => char.IsDigit(c) || c == '.'); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixtureTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixtureTests.cs new file mode 100644 index 00000000..ff5b4860 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixtureTests.cs @@ -0,0 +1,43 @@ +using GenHub.Tests.Core.Features.GameProfiles; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Tests for the shared native-client fixture helper. +/// +/// +/// The integration tests that use it all skip without a local engine install, so the +/// library predicate would otherwise never be exercised on CI — including the versioned +/// Linux forms, which a plain ".so" suffix test silently misses. +/// +public class NativeClientFixtureTests +{ + /// + /// Recognises macOS and both Linux shapes, unversioned and versioned. + /// + /// The candidate filename. + [Theory] + [InlineData("libSDL3.dylib")] + [InlineData("libSDL3.so")] + [InlineData("libSDL3.so.0")] + [InlineData("libSDL3.so.0.1.0")] + [InlineData("libstdc++.so.6")] + [InlineData("libavcodec.58.so.4")] + [InlineData("mylib.sound.so.1")] + public void IsDynamicLibrary_WithLibraryNames_ReturnsTrue(string fileName) + => Assert.True(NativeClientFixture.IsDynamicLibrary(fileName)); + + /// + /// Rejects the engine binary, data files, and names that merely contain ".so". + /// + /// The candidate filename. + [Theory] + [InlineData("generalszh")] + [InlineData("INIZH.big")] + [InlineData("readme.txt")] + [InlineData("resources.sound")] + [InlineData("libfoo.sox")] + public void IsDynamicLibrary_WithNonLibraryNames_ReturnsFalse(string fileName) + => Assert.False(NativeClientFixture.IsDynamicLibrary(fileName)); +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchCollection.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchCollection.cs new file mode 100644 index 00000000..69fe14a9 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchCollection.cs @@ -0,0 +1,21 @@ +using Xunit; + +using System.Runtime.InteropServices; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Serialises the tests that launch a real native game client. +/// +/// 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. +/// +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class NativeClientLaunchCollection +{ + /// The xUnit collection name. + public const string Name = "Native client launch"; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs new file mode 100644 index 00000000..209152f3 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs @@ -0,0 +1,152 @@ +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; + +/// +/// End-to-end launch of a real native Zero Hour client through GenHub's own process +/// manager, rather than a stand-in script. +/// +/// 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. +/// +/// +/// Skipped unless a native install is present, so CI and other machines stay green. +/// Point GENHUB_NATIVE_CLIENT_DIR at an install directory to run it; the default +/// is the deploy script's own default location. +/// +/// +[Collection(NativeClientLaunchCollection.Name)] +public class NativeClientLaunchIntegrationTests +{ + /// How long the engine must stay up to count as a successful launch. + private static readonly TimeSpan LaunchSettleTime = TimeSpan.FromSeconds(12); + + private readonly GameProcessManager _processManager = new(NullLogger.Instance); + + /// + /// Launches the engine with the install directory as the working directory, exactly + /// as a workspace launch would, and requires it to survive startup. + /// + /// Windowed mode is requested so a test run cannot take over the display. + /// + /// + /// A task representing the asynchronous test. + [Fact] + public async Task RealNativeClient_LaunchesThroughGameProcessManager() + { + var installDirectory = NativeClientFixture.Directory; + if (installDirectory is null) + { + return; + } + + var configuration = new GameLaunchConfiguration + { + ExecutablePath = Path.Combine(installDirectory, NativeClientFixture.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); + } + } + + /// + /// The working directory is load-bearing, not cosmetic. The engine discovers its + /// .big 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. + /// + /// This is why WorkspaceStrategyBase sets WorkingDirectory to the + /// workspace root, and why a native client cannot simply be launched in place. + /// + /// + /// A task representing the asynchronous test. + [Fact] + public async Task RealNativeClient_RequiresItsInstallDirectoryAsWorkingDirectory() + { + var installDirectory = NativeClientFixture.Directory; + 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, NativeClientFixture.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. + } + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs new file mode 100644 index 00000000..8c9ec31f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +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.Core.Utilities; +using GenHub.Features.GameProfiles.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Closes the loop between the manifest model and a real launch. +/// +/// The variant and entry-point model is otherwise only exercised against hand-built +/// manifests. This builds a manifest from an actual native install, resolves the entry +/// point the way the workspace does, and launches whatever comes out — so a resolver +/// that picks the wrong file fails here rather than in production. +/// +/// +/// Skipped unless a native install is present. Set GENHUB_NATIVE_CLIENT_DIR to +/// override the default location. +/// +/// +[Collection(NativeClientLaunchCollection.Name)] +public class NativeClientManifestLaunchTests +{ + private readonly GameProcessManager _processManager = new(NullLogger.Instance); + + /// + /// Builds a manifest describing a real install and launches the entry point the + /// resolver selects. + /// + /// The manifest deliberately includes the engine's dynamic libraries. That is the + /// shape that used to break resolution: several files qualify as executable, so + /// picking the first one was picking by enumeration order. + /// + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ManifestResolvedEntryPoint_LaunchesTheRealEngine() + { + var installDirectory = NativeClientFixture.Directory; + if (installDirectory is null) + { + return; + } + + var manifest = BuildManifestFromInstall(installDirectory); + + // Sanity-check the fixture actually reproduces the ambiguous case; otherwise this + // test would pass for the wrong reason. + Assert.True( + manifest.Variants.Single().Files.Count(f => f.IsExecutable) >= 1, + "Expected the install to contain at least one file requiring execute permission."); + + // Without this the fixture could contain only the binary and still satisfy the + // assertions, leaving the library-handling path untested on either platform. + Assert.Contains( + manifest.Variants.Single().Files, + file => NativeClientFixture.IsDynamicLibrary(Path.GetFileName(file.RelativePath))); + + var resolution = ManifestVariantResolver.ResolveEntryPoint(manifest); + + Assert.True(resolution.Success, resolution.ToString()); + Assert.Equal(NativeClientFixture.BinaryName, resolution.RelativePath); + + var configuration = new GameLaunchConfiguration + { + ExecutablePath = Path.Combine(installDirectory, resolution.RelativePath!), + WorkingDirectory = installDirectory, + Arguments = new() { ["-win"] = string.Empty }, + }; + + var result = await _processManager.StartProcessAsync(configuration); + Assert.True(result.Success, $"Launch failed: {string.Join(" ", result.Errors)}"); + + try + { + await Task.Delay(TimeSpan.FromSeconds(12)); + var info = await _processManager.GetProcessInfoAsync(result.Data!.ProcessId); + Assert.True(info.Success, "The engine exited during initialisation."); + } + finally + { + await _processManager.TerminateProcessAsync(result.Data!.ProcessId); + } + } + + /// + /// A manifest whose variant does not target this runtime must resolve to nothing, + /// so a Windows-only client is never offered on macOS. + /// + [Fact] + public void ManifestForAnotherRuntime_IsNotOfferedHere() + { + var installDirectory = NativeClientFixture.Directory; + if (installDirectory is null) + { + return; + } + + var manifest = BuildManifestFromInstall(installDirectory); + manifest.Variants.Single().RuntimeIdentifiers = ["win-x64"]; + + Assert.False(ManifestVariantResolver.SupportsRuntime(manifest)); + Assert.Empty(ManifestVariantResolver.ResolveFiles(manifest)); + } + + /// + /// Builds a single-variant manifest describing the engine binary and its libraries, + /// classified by the shared rules rather than by hand. + /// + /// The native install directory. + /// A manifest targeting the current runtime. + private static ContentManifest BuildManifestFromInstall(string installDirectory) + { + var engineFiles = new List(); + + foreach (var path in Directory.EnumerateFiles(installDirectory, "*", SearchOption.TopDirectoryOnly)) + { + var name = Path.GetFileName(path); + + // The engine and its bundled libraries; retail archives are the user's own + // content and belong to the installation, not the client manifest. + if (name != NativeClientFixture.BinaryName && !NativeClientFixture.IsDynamicLibrary(name)) + { + continue; + } + + engineFiles.Add(new ManifestFile + { + RelativePath = name, + IsExecutable = ExecutableFileClassifier.RequiresExecutePermission(name), + }); + } + + return new ContentManifest + { + Name = "Native Zero Hour (BGFX)", + Variants = + [ + new ArtifactVariant + { + RuntimeIdentifiers = [ManifestVariantResolver.CurrentRuntimeIdentifier], + EntryPoint = NativeClientFixture.BinaryName, + Files = engineFiles, + }, + ], + }; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs new file mode 100644 index 00000000..c2efd342 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using GenHub.Core.Models.Launching; +using GenHub.Features.GameProfiles.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Verifies that a native client which fails to start says why. +/// +/// The failure this guards against is silence. A binary missing its execute bit, or one +/// that dies in the dynamic loader, previously produced no window and no error: the +/// process started, exited, and the launcher reported an exit code with no context. On a +/// native client whose libraries sit beside it in the workspace, that is the most likely +/// first-run failure of all. +/// +/// +public class NativeLaunchDiagnosticsTests : IDisposable +{ + private readonly string _tempDir = Path.Combine( + Path.GetTempPath(), + $"genhub-launchdiag-{Guid.NewGuid():N}"); + + private readonly GameProcessManager _processManager = new(NullLogger.Instance); + + /// + /// Initializes a new instance of the class. + /// + public NativeLaunchDiagnosticsTests() => Directory.CreateDirectory(_tempDir); + + private static bool OnUnix => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// + /// A file without the execute bit must be refused before launch, naming the file. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task NonExecutableFile_IsRefusedWithANamedError() + { + if (!OnUnix) + { + return; + } + + var binary = Path.Combine(_tempDir, NativeClientFixture.BinaryName); + await File.WriteAllTextAsync(binary, "#!/bin/sh\nexit 0\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(binary, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + var result = await _processManager.StartProcessAsync(new GameLaunchConfiguration + { + ExecutablePath = binary, + WorkingDirectory = _tempDir, + }); + + Assert.False(result.Success); + Assert.Contains("execute permission", string.Join(" ", result.Errors), StringComparison.OrdinalIgnoreCase); + Assert.Contains(NativeClientFixture.BinaryName, string.Join(" ", result.Errors)); + } + + /// + /// A process that dies during startup must surface what it wrote to standard error, + /// not just its exit code. "dyld: library not loaded" identifies the problem; "exit + /// code 1" does not. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ProcessThatDiesAtStartup_SurfacesItsStderr() + { + if (!OnUnix) + { + return; + } + + var binary = Path.Combine(_tempDir, NativeClientFixture.BinaryName); + await File.WriteAllTextAsync( + binary, + "#!/bin/sh\necho \"dyld: Library not loaded: @rpath/libSDL3.dylib\" >&2\nexit 1\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + binary, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var result = await _processManager.StartProcessAsync(new GameLaunchConfiguration + { + ExecutablePath = binary, + WorkingDirectory = _tempDir, + }); + + Assert.False(result.Success); + + var message = string.Join(" ", result.Errors); + Assert.Contains("libSDL3.dylib", message); + Assert.Contains("Library not loaded", message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// A healthy process must still launch normally with stderr redirection enabled. + /// Redirecting a stream that is never drained is a classic way to deadlock a chatty + /// child process, so this confirms the drain works. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ChattyProcess_StillLaunchesWithoutDeadlocking() + { + if (!OnUnix) + { + return; + } + + var binary = Path.Combine(_tempDir, NativeClientFixture.BinaryName); + + // Writes far more than a pipe buffer holds, then keeps running. + await File.WriteAllTextAsync( + binary, + "#!/bin/sh\ni=0\nwhile [ $i -lt 2000 ]; do echo \"log line $i padding padding padding\" >&2; i=$((i+1)); done\nsleep 30\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + binary, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var result = await _processManager.StartProcessAsync(new GameLaunchConfiguration + { + ExecutablePath = binary, + WorkingDirectory = _tempDir, + }); + + Assert.True(result.Success, $"Launch failed: {string.Join(" ", result.Errors)}"); + + if (result.Data is not null) + { + await _processManager.TerminateProcessAsync(result.Data.ProcessId); + } + } + + /// + /// Releases the temporary directory. + /// + public void Dispose() + { + GC.SuppressFinalize(this); + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch (IOException) + { + // A leftover temp directory is not worth failing a test over. + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs new file mode 100644 index 00000000..a743de6b --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs @@ -0,0 +1,192 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Launching; +using GenHub.Features.GameProfiles.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Verifies that a workspace containing only the engine can reach the user's retail +/// archives through the environment, instead of materialising ~3 GB per profile. +/// +/// Zero Hour mounts *.big from the working directory plus any root named by +/// InstallPath, which the non-Windows engine resolves from +/// $CNC_ZH_INSTALLPATH and $CNC_GENERALS_INSTALLPATH. Zero Hour needs the +/// base Generals archives as well as its own, so without this every profile workspace +/// has to contain both games in full. +/// +/// +/// Skipped unless a native install is present. Set GENHUB_NATIVE_CLIENT_DIR to +/// override the default location. +/// +/// +[Collection(NativeClientLaunchCollection.Name)] +public class RetailArchiveRootTests : IDisposable +{ + private readonly string _engineOnlyWorkspace = Path.Combine( + Path.GetTempPath(), + $"genhub-engine-only-{Guid.NewGuid():N}"); + + private readonly GameProcessManager _processManager = new(NullLogger.Instance); + + /// + /// An engine-only workspace plus environment-supplied archive roots must launch and + /// stay up, with no .big file anywhere in the workspace. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EngineOnlyWorkspace_ReachesRetailArchivesThroughEnvironment() + { + var installDirectory = NativeClientFixture.Directory; + if (installDirectory is null) + { + return; + } + + StageEngineOnly(installDirectory); + + Assert.Empty(Directory.GetFiles(_engineOnlyWorkspace, "*.big")); + + var configuration = new GameLaunchConfiguration + { + ExecutablePath = Path.Combine(_engineOnlyWorkspace, NativeClientFixture.BinaryName), + WorkingDirectory = _engineOnlyWorkspace, + Arguments = new() { ["-win"] = string.Empty }, + EnvironmentVariables = new() + { + // Trailing separator is required; see AddArchiveRoot in GameLauncher. + [RetailArchiveConstants.ZeroHourInstallPathVariable] = installDirectory + Path.DirectorySeparatorChar, + [RetailArchiveConstants.GeneralsInstallPathVariable] = installDirectory + Path.DirectorySeparatorChar, + }, + }; + + var result = await _processManager.StartProcessAsync(configuration); + Assert.True(result.Success, $"Launch failed: {string.Join(" ", result.Errors)}"); + + try + { + await Task.Delay(TimeSpan.FromSeconds(14)); + var info = await _processManager.GetProcessInfoAsync(result.Data!.ProcessId); + const string failureMessage = + "The engine exited despite the archive roots being supplied, so retail data " + + "was not reached through the environment."; + + Assert.True(info.Success, failureMessage); + } + finally + { + await _processManager.TerminateProcessAsync(result.Data!.ProcessId); + } + } + + /// + /// Without the archive roots the same workspace must fail, which is what makes the + /// test above meaningful rather than a coincidence. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EngineOnlyWorkspace_WithoutArchiveRoots_DoesNotSurvive() + { + var installDirectory = NativeClientFixture.Directory; + if (installDirectory is null) + { + return; + } + + StageEngineOnly(installDirectory); + + var configuration = new GameLaunchConfiguration + { + ExecutablePath = Path.Combine(_engineOnlyWorkspace, NativeClientFixture.BinaryName), + WorkingDirectory = _engineOnlyWorkspace, + Arguments = new() { ["-win"] = string.Empty }, + }; + + var result = await _processManager.StartProcessAsync(configuration); + + if (!result.Success) + { + return; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(14)); + var info = await _processManager.GetProcessInfoAsync(result.Data!.ProcessId); + const string failureMessage = + "An engine-only workspace survived with no archive roots at all. If the engine " + + "now locates retail data by some other means, the environment plumbing in " + + "GameLauncher.AddRetailArchiveRoots may be unnecessary."; + + Assert.False(info.Success, failureMessage); + } + finally + { + await _processManager.TerminateProcessAsync(result.Data!.ProcessId); + } + } + + /// + /// Releases the staged workspace. + /// + public void Dispose() + { + GC.SuppressFinalize(this); + try + { + if (Directory.Exists(_engineOnlyWorkspace)) + { + Directory.Delete(_engineOnlyWorkspace, recursive: true); + } + } + catch (IOException) + { + // A leftover temp directory is not worth failing a test over. + } + } + + /// + /// Copies just the engine and its dynamic libraries, deliberately leaving out every + /// archive and data directory. + /// + /// The source install. + private void StageEngineOnly(string installDirectory) + { + Directory.CreateDirectory(_engineOnlyWorkspace); + + var engineFiles = Directory + .EnumerateFiles(installDirectory, "*", SearchOption.TopDirectoryOnly) + .Where(path => + { + // Both Unix shared-library extensions: the engine ships .dylib on macOS and + // .so on Linux, and staging only one leaves the workspace incomplete there. + var name = Path.GetFileName(path); + return name == NativeClientFixture.BinaryName + || NativeClientFixture.IsDynamicLibrary(name); + }); + + foreach (var source in engineFiles) + { + var destination = Path.Combine(_engineOnlyWorkspace, Path.GetFileName(source)); + File.Copy(source, destination, overwrite: true); + } + + const UnixFileMode executableMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + Path.Combine(_engineOnlyWorkspace, NativeClientFixture.BinaryName), + executableMode); + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs new file mode 100644 index 00000000..3aef47b3 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs @@ -0,0 +1,74 @@ +using System.Runtime.InteropServices; +using GenHub.Core.Models.Launching; +using GenHub.Features.GameProfiles.Infrastructure; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// End-to-end check that stderr from a process which dies immediately is still captured. +/// +/// +/// Covers the capture end to end: a real process fails, and both the first and last of +/// its stderr survive into the reported error. The buffer's own tests exercise retention +/// in isolation; this proves the wiring — handler, drain, buffer and error message — +/// actually delivers them to the caller. +/// +/// It does not deterministically pin the end-of-stream drain. Removing the +/// WaitForExit() call leaves this test passing on macOS and .NET 8, because the +/// handlers happen to complete before the capture is read even at twenty thousand lines. +/// The drain is retained on the documented contract — the parameterless overload waits +/// for redirected-output handlers, the timed ones do not — rather than on a failing test +/// here. A platform with different scheduling may well expose it. +/// +/// +public class StderrCaptureRaceTests +{ + private const string HeadLine = "GENHUB-HEAD-MARKER"; + private const string TailLine = "GENHUB-TAIL-MARKER"; + + /// + /// A process that writes a head line, floods the buffer, writes a tail line and exits + /// non-zero must have both ends of its output reported in the failure. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task StartProcessAsync_WithImmediateFailure_CapturesBothEndsOfStderr() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // Arguments are key/value pairs; a leading '-' key is emitted as a flag followed + // by its value, which produces `/bin/sh -c