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_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;
}
}
}