using System; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Net; using System.Net.Sockets; using System.Threading; using Tesla.Net; using VPod; using Xunit; namespace TeslaConsole.DiffTests { /// /// End-to-end loopback exercise of vPOD's virtual launcher: the REAL /// console-side client (PodManagerConnection) against vPOD's LauncherRpcServer /// on an ephemeral port. Covers the OFB/CONF handshake, the ILauncherService /// dispatch surface, and the out-of-band InstallProduct transfer — including /// the 99%-complete convention the console's install retry loop depends on. /// This validates both ends: the client the console ships and the server vPOD /// uses to stand in for a pod's TeslaLauncher service. /// public class VPodLauncherServerTests : IDisposable { private readonly string mDataDir; private readonly VirtualLauncher mLauncher; private readonly LauncherRpcServer mServer; private readonly PodManagerConnection mClient; private readonly byte[] mKey; private readonly int mPort; public VPodLauncherServerTests() { mDataDir = Path.Combine(Path.GetTempPath(), "vpod-test-" + Guid.NewGuid().ToString("N")); // Isolated games root: vPOD's default is the real C:\Games (launcher parity). mLauncher = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games")); mKey = new byte[32]; new Random(1234).NextBytes(mKey); mPort = GetFreePort(); mServer = new LauncherRpcServer(mLauncher, mPort); mServer.Start(mKey); mClient = new PodManagerConnection(); mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey); } public void Dispose() { try { mClient.Close(); } catch { } mServer.Stop(); try { Directory.Delete(mDataDir, recursive: true); } catch { } } [Fact] public void Handshake_And_Ping_RoundTrip() { Assert.True(mClient.IsOpen); var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc); Assert.Equal(now, mClient.Ping(now)); } [Fact] public void Handshake_With_Wrong_Key_Is_Rejected() { var wrongKey = new byte[32]; new Random(9999).NextBytes(wrongKey); using (var badClient = new PodManagerConnection()) { Assert.Throws( () => badClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), wrongKey)); } } [Fact] public void InstallApp_Appears_In_GetInstalledApps_And_FullUpdate() { var app = SampleApp("Red Planet 4.11"); mClient.InstallApp(app); var installed = mClient.GetInstalledApps(); Assert.Single(installed); Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey); Assert.Equal(app.ExeFile, installed[0].ExeFile); Assert.Equal(app.Arguments, installed[0].Arguments); Assert.True(installed[0].AutoRestart); var launchable = mClient.GetLaunchableApps(); Assert.Single(launchable); Assert.Equal("Red Planet 4.11", launchable[0].DisplayName); var full = mClient.FullUpdate(); Assert.Single(full.InstalledApps); Assert.Empty(full.LaunchedApps); } [Fact] public void LaunchApp_Simulates_Pids_And_Kill_Removes_Them() { var app = SampleApp("BattleTech 4.11"); mClient.InstallApp(app); int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey); Assert.True(pid > 0); var launched = mClient.GetLaunchedApps(); Assert.Single(launched); Assert.Equal(pid, launched[0].ProcessId); Assert.Equal(app.LaunchPair.LaunchKey, launched[0].LaunchKey); mClient.KillApp(app.LaunchPair.LaunchKey, pid); Assert.Empty(mClient.GetLaunchedApps()); mClient.LaunchApp(app.LaunchPair.LaunchKey); mClient.LaunchApp(app.LaunchPair.LaunchKey); mClient.KillAllApps(); Assert.Empty(mClient.GetLaunchedApps()); } [Fact] public void VolumeLevel_RoundTrips() { mClient.VolumeLevel = 0.25f; Assert.Equal(0.25f, mClient.VolumeLevel); Assert.Equal(0.25f, mClient.FullUpdate().VolumeLevel); } [Fact] public void InstallProduct_Streams_Extracts_And_Completes_At_99() { string zipPath = MakeTestZip("TestGame", "readme.txt", "hello vpod"); Guid callId = mClient.InstallProduct(zipPath); Assert.NotEqual(Guid.Empty, callId); var progress = PollUntilCompleted(callId); // 99, not 100: the console's InstallProductWorker only breaks its // 3-attempt retry loop on exactly 99. Assert.Equal(99, progress.PercentComplete); Assert.Equal("Complete", progress.Status); string extracted = Path.Combine(mDataDir, "Games", "TestGame", "readme.txt"); Assert.True(File.Exists(extracted), "expected extracted file at " + extracted); Assert.Equal("hello vpod", File.ReadAllText(extracted)); // The main connection survived the concurrent out-of-band transfer. Assert.True(mClient.IsOpen); var now = DateTime.UtcNow; mClient.Ping(now); } [Fact] public void UninstallApp_Removes_Registration_And_Product_Directory() { string zipPath = MakeTestZip("TestGame", "game.exe", "not really an exe"); Guid callId = mClient.InstallProduct(zipPath); PollUntilCompleted(callId); string productDir = Path.Combine(mDataDir, "Games", "TestGame"); var app = new LaunchData { LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Test Game" }, WorkingDirectory = productDir, ExeFile = Path.Combine(productDir, "game.exe"), Arguments = "", AutoRestart = false }; mClient.InstallApp(app); Assert.Single(mClient.GetInstalledApps()); Assert.True(Directory.Exists(productDir)); mClient.UninstallApp(app.LaunchPair.LaunchKey); Assert.Empty(mClient.GetInstalledApps()); Assert.False(Directory.Exists(productDir), "product directory should be cleaned up"); } [Fact] public void Shutdown_Raises_ShutdownRequested() { bool? restartArg = null; using (var signalled = new ManualResetEventSlim()) { mLauncher.ShutdownRequested += restart => { restartArg = restart; signalled.Set(); }; mClient.Shutdown(doRestart: true); Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ShutdownRequested was not raised"); Assert.True(restartArg); } } [Fact] public void ClearStore_Wipes_Apps_And_Requests_Reprovisioning() { mClient.InstallApp(SampleApp("Doomed App")); using (var signalled = new ManualResetEventSlim()) { mLauncher.ReprovisionRequested += signalled.Set; mClient.ClearStore(); // one-way: no response frame Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ReprovisionRequested was not raised"); } Assert.Empty(mLauncher.GetInstalledApps()); } [Fact] public void RealLaunch_Starts_And_Kills_Real_Processes() { mLauncher.RealLaunch = true; var app = new LaunchData { LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Real Pinger" }, WorkingDirectory = Environment.SystemDirectory, ExeFile = Path.Combine(Environment.SystemDirectory, "ping.exe"), Arguments = "-n 60 127.0.0.1", AutoRestart = false }; mClient.InstallApp(app); int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey); Assert.True(pid > 0); using (var real = Process.GetProcessById(pid)) // throws if not actually running { Assert.False(real.HasExited); var launched = mClient.GetLaunchedApps(); Assert.Single(launched); Assert.Equal(pid, launched[0].ProcessId); mClient.KillAllOfType(app.LaunchPair.LaunchKey); Assert.True(real.WaitForExit(5000), "real process was not terminated"); } Assert.Empty(mClient.GetLaunchedApps()); } [Fact] public void RealLaunch_Watchdog_Restarts_AutoRestart_Apps_That_Exit_On_Their_Own() { mLauncher.RealLaunch = true; mLauncher.RealAutoRestart = true; var app = new LaunchData { LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Short Pinger" }, WorkingDirectory = Environment.SystemDirectory, ExeFile = Path.Combine(Environment.SystemDirectory, "ping.exe"), Arguments = "-n 3 127.0.0.1", // exits on its own after ~2 s AutoRestart = true }; mClient.InstallApp(app); int firstPid = mClient.LaunchApp(app.LaunchPair.LaunchKey); Assert.True(firstPid > 0); // The app exits by itself; the watchdog must bring up a NEW pid // (exit ~2 s + the Agent's 2 s restart delay). int restartedPid = 0; var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); while (DateTime.UtcNow < deadline) { var launched = mClient.GetLaunchedApps(); if (launched.Length == 1 && launched[0].ProcessId != firstPid) { restartedPid = launched[0].ProcessId; break; } Thread.Sleep(100); } Assert.True(restartedPid > 0, "watchdog did not restart the exited app"); // Turning the watchdog off ends the cycle: the current instance // exits on its own and nothing relaunches it. mLauncher.RealAutoRestart = false; deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); while (DateTime.UtcNow < deadline && mClient.GetLaunchedApps().Length > 0) { Thread.Sleep(200); } Assert.Empty(mClient.GetLaunchedApps()); } [Fact] public void RealLaunch_Missing_Exe_Surfaces_The_Agents_Error() { mLauncher.RealLaunch = true; var app = SampleApp("Not Installed Yet"); // ExeFile doesn't exist on disk mClient.InstallApp(app); var ex = Assert.ThrowsAny(() => mClient.LaunchApp(app.LaunchPair.LaunchKey)); Assert.Contains("executable not found", ex.Message); Assert.Empty(mClient.GetLaunchedApps()); } [Fact] public void Dropped_Session_Still_Reports_Open_Until_Probed_Then_Reconnect_Works() { // The launcher (and vPOD) drops sessions idle >30s. Simulate the drop // server-side and pin the premise PodInfo.EnsureConnectionAlive relies // on: the client socket still REPORTS open until an I/O fails... mServer.Stop(); mServer.Start(mKey); Thread.Sleep(100); // let the FIN arrive Assert.True(mClient.IsOpen, "a dropped-but-unused connection should still report open (the stale state)"); // ...the cheap Ping probe is what exposes the dead connection... Assert.ThrowsAny(() => mClient.Ping(DateTime.UtcNow)); // ...and close + reopen (EnsureConnectionAlive's recovery) restores service. mClient.Close(); mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey); var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc); Assert.Equal(now, mClient.Ping(now)); } [Fact] public void Installed_Apps_Persist_Across_Launcher_Restart() { var app = SampleApp("Persistent App"); mClient.InstallApp(app); var reloaded = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games")); var installed = reloaded.GetInstalledApps(); Assert.Single(installed); Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey); Assert.Equal("Persistent App", installed[0].LaunchPair.DisplayName); } // ---- helpers ---- private static LaunchData SampleApp(string name) { return new LaunchData { LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = name }, WorkingDirectory = @"C:\Games\Sample", ExeFile = @"C:\Games\Sample\game.exe", Arguments = "-net 1501 -res 1920 1080", AutoRestart = true }; } private string MakeTestZip(string folder, string fileName, string content) { string zipPath = Path.Combine(mDataDir, "product-" + Guid.NewGuid().ToString("N") + ".zip"); using (var fs = File.Create(zipPath)) using (var zip = new ZipArchive(fs, ZipArchiveMode.Create)) { var entry = zip.CreateEntry(folder + "/" + fileName); using (var writer = new StreamWriter(entry.Open())) { writer.Write(content); } } return zipPath; } private OutOfBandProgress PollUntilCompleted(Guid callId) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); while (DateTime.UtcNow < deadline) { var progress = mClient.GetOutOfBandProgress(callId); if (progress.IsCompleted) { return progress; } Thread.Sleep(50); } throw new TimeoutException("Install did not complete in time."); } private static int GetFreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); int port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } } }