vPOD now also impersonates the pod's TeslaLauncher service, so the console's Manage Site works against it unmodified: - LauncherRpcServer: ILauncherService over OFB + framed JSON on TCP 53290, mirroring TeslaLauncherService (concurrent sessions, out-of-band install zip on a second connection, the 99%-not-100 completion convention). Packages extract to %LocalAppData%\vPOD\Games; postinstall.bat is logged but never executed. - PodProvisioning: pod side of SecureConfig (RQST beacon, RPLY decrypt, RSA session-key exchange), display-only — never touches the NIC/registry. The console's Configure flow mints the key exactly as for a real pod; console Reconfigure (ClearStore) drops the key and re-enters beacon mode. - VirtualLauncher: installed-app registry (persisted), simulated launch PIDs, volume, install progress; console Shutdown/Restart power-cycles the pod. - Form gets a Launcher/Site Management column (passcode display, RPC status, install progress, app list, Reprovision); Power Off darkens the launcher side too; new -nomanage flag disables it. vPOD references the shared Tesla.Contract/Tesla.SecureConfig projects (server side of the existing contract only, no new RPCs). Loopback tests drive the real PodManagerConnection and PodConfigurationServer against the new code (VPodLauncherServerTests, VPodProvisioningTests) — suite now 99 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
269 lines
9.9 KiB
C#
269 lines
9.9 KiB
C#
using System;
|
|
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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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"));
|
|
mLauncher = new VirtualLauncher(mDataDir);
|
|
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<IOException>(
|
|
() => 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 Installed_Apps_Persist_Across_Launcher_Restart()
|
|
{
|
|
var app = SampleApp("Persistent App");
|
|
mClient.InstallApp(app);
|
|
|
|
var reloaded = new VirtualLauncher(mDataDir);
|
|
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;
|
|
}
|
|
}
|
|
}
|