merge: Campaign LA LA4 - Avalonia launcher review-closed
This commit is contained in:
commit
60f627998c
35 changed files with 6485 additions and 49 deletions
|
|
@ -370,6 +370,41 @@ public sealed class SessionConfigComposerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeProbeAndWriteWritesThePasswordFreeProbeDocument()
|
||||
{
|
||||
string root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-launcher-probe-writer-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var paths = new ApplicationPathSet(
|
||||
Path.Combine(root, "cfg"),
|
||||
Path.Combine(root, "data"),
|
||||
Path.Combine(root, "cache"),
|
||||
null);
|
||||
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbeAndWrite(
|
||||
Server(),
|
||||
Account(),
|
||||
Install,
|
||||
paths,
|
||||
"probe-write");
|
||||
|
||||
string json = File.ReadAllText(composed.ConfigFilePath);
|
||||
Assert.Contains("\"mode\": \"probe\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"character\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Account().Password, json, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject ParseRoot(ComposedSessionConfig composed)
|
||||
{
|
||||
string json = SessionConfigComposer.Serialize(composed.Document);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Orchestration;
|
||||
|
||||
public sealed class LauncherExecutableSetTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-launcher-layout-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDirectoryResolvesThePublishedCoDeploymentLayout()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
string graphical = Path.Combine(_root, "AcDream.App" + suffix);
|
||||
string headless = Path.Combine(_root, "acdream-headless" + suffix);
|
||||
File.WriteAllText(graphical, string.Empty);
|
||||
File.WriteAllText(headless, string.Empty);
|
||||
MakeExecutableOnLinux(graphical);
|
||||
MakeExecutableOnLinux(headless);
|
||||
|
||||
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
|
||||
|
||||
Assert.Equal(Path.GetFullPath(_root), set.WorkingDirectory);
|
||||
Assert.Equal(graphical, set.GraphicalHostPath);
|
||||
Assert.Equal(headless, set.HeadlessHostPath);
|
||||
Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable);
|
||||
Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable);
|
||||
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
|
||||
Assert.Equal(
|
||||
graphical,
|
||||
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json").ExecutablePath);
|
||||
Assert.Equal(
|
||||
headless,
|
||||
set.CreateProbeSpec("session.json").ExecutablePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingPublishedHostsHaveSpecificUnavailableReasons()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
|
||||
|
||||
LauncherCapability gui = set.GetAvailability(LaunchMode.Gui);
|
||||
LauncherCapability probe = set.GetAvailability(LaunchMode.Headless);
|
||||
|
||||
Assert.False(gui.IsAvailable);
|
||||
Assert.Contains("graphical client", gui.Reason, StringComparison.Ordinal);
|
||||
Assert.Contains(set.GraphicalHostPath, gui.Reason, StringComparison.Ordinal);
|
||||
Assert.False(probe.IsAvailable);
|
||||
Assert.Contains("headless host", probe.Reason, StringComparison.Ordinal);
|
||||
Assert.Contains(set.HeadlessHostPath, probe.Reason, StringComparison.Ordinal);
|
||||
Assert.Throws<LauncherOperationException>(() =>
|
||||
set.CreatePlaySpec(LaunchMode.Gui, "session.json"));
|
||||
Assert.Throws<LauncherOperationException>(() =>
|
||||
set.CreateProbeSpec("session.json"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinuxRequiresExecutePermissionForBothCoDeployedHosts()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_root);
|
||||
string graphical = Path.Combine(_root, "AcDream.App");
|
||||
string headless = Path.Combine(_root, "acdream-headless");
|
||||
File.WriteAllText(graphical, string.Empty);
|
||||
File.WriteAllText(headless, string.Empty);
|
||||
UnixFileMode notExecutable = UnixFileMode.UserRead | UnixFileMode.UserWrite
|
||||
| UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
File.SetUnixFileMode(graphical, notExecutable);
|
||||
File.SetUnixFileMode(headless, notExecutable);
|
||||
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
|
||||
|
||||
LauncherCapability gui = set.GetAvailability(LaunchMode.Gui);
|
||||
LauncherCapability headlessCapability =
|
||||
set.GetAvailability(LaunchMode.Headless);
|
||||
|
||||
Assert.False(gui.IsAvailable);
|
||||
Assert.Contains("not executable", gui.Reason, StringComparison.Ordinal);
|
||||
Assert.Contains("chmod +x", gui.Reason, StringComparison.Ordinal);
|
||||
Assert.False(headlessCapability.IsAvailable);
|
||||
Assert.Contains("not executable", headlessCapability.Reason, StringComparison.Ordinal);
|
||||
Assert.Throws<LauncherOperationException>(() =>
|
||||
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json"));
|
||||
Assert.Throws<LauncherOperationException>(() =>
|
||||
set.CreateProbeSpec("session.json"));
|
||||
|
||||
MakeExecutableOnLinux(graphical);
|
||||
MakeExecutableOnLinux(headless);
|
||||
|
||||
Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable);
|
||||
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsPreservesExistenceOnlyAvailability()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var set = new LauncherExecutableSet(
|
||||
"graphical.exe",
|
||||
"headless.exe",
|
||||
fileExists: _ => true,
|
||||
hasUnixExecutePermission: _ => false);
|
||||
|
||||
Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable);
|
||||
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
|
||||
}
|
||||
|
||||
private static void MakeExecutableOnLinux(string path)
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
File.SetUnixFileMode(
|
||||
path,
|
||||
UnixFileMode.UserRead
|
||||
| UnixFileMode.UserWrite
|
||||
| UnixFileMode.UserExecute
|
||||
| UnixFileMode.GroupRead
|
||||
| UnixFileMode.GroupExecute
|
||||
| UnixFileMode.OtherRead
|
||||
| UnixFileMode.OtherExecute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,730 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Status;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Orchestration;
|
||||
|
||||
public sealed class LauncherOrchestratorTests : IDisposable
|
||||
{
|
||||
private const string Password = "launcher-only-secret";
|
||||
private readonly string _root;
|
||||
private readonly ApplicationPathSet _paths;
|
||||
|
||||
public LauncherOrchestratorTests()
|
||||
{
|
||||
_root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-la4-orchestrator-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_root);
|
||||
_paths = new ApplicationPathSet(
|
||||
Path.Combine(_root, "config"),
|
||||
Path.Combine(_root, "data"),
|
||||
Path.Combine(_root, "cache"),
|
||||
null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
|
||||
{
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator();
|
||||
|
||||
LauncherStateSnapshot snapshot = orchestrator.GetSnapshot();
|
||||
|
||||
LauncherServerSnapshot server = Assert.Single(snapshot.Servers);
|
||||
LauncherAccountSnapshot account = Assert.Single(server.Accounts);
|
||||
LauncherCharacterSnapshot character = Assert.Single(account.Characters);
|
||||
Assert.Equal("Local ACE", server.Name);
|
||||
Assert.Equal("testaccount", account.AccountName);
|
||||
Assert.Equal("+Acdream", character.Name);
|
||||
|
||||
string serialized = System.Text.Json.JsonSerializer.Serialize(snapshot);
|
||||
Assert.DoesNotContain(Password, serialized, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
typeof(LauncherAccountSnapshot).GetProperties(),
|
||||
property => property.Name.Contains("password", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Name.Contains("credential", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LaunchComposesTheSelectedModeAndSpawnsThroughTheInjectedSeam()
|
||||
{
|
||||
var config = new RecordingConfigService();
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
var statusSources = new QueueStatusSourceFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
configService: config,
|
||||
supervisorFactory: supervisors,
|
||||
statusSourceFactory: statusSources);
|
||||
|
||||
LauncherSessionSnapshot launched = await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Gui);
|
||||
|
||||
Assert.Equal(LauncherActivityState.Running, launched.State);
|
||||
Assert.Equal(1, config.PlayCallCount);
|
||||
Assert.Equal(0, config.ProbeCallCount);
|
||||
Assert.Equal(LaunchMode.Gui, config.LastCharacter!.LaunchMode);
|
||||
Assert.Equal(["ExamplePlugin"], config.LastCharacter.Plugins);
|
||||
Assert.Equal(["/vt start"], config.LastCharacter.LoginCommands);
|
||||
Assert.Equal(string.Empty, config.LastAccountPassword);
|
||||
|
||||
FakeSupervisor supervisor = Assert.Single(supervisors.Created);
|
||||
Assert.Equal("gui-host", supervisor.Spec!.ExecutablePath);
|
||||
Assert.Equal(
|
||||
["--session-config", config.LastComposed!.ConfigFilePath],
|
||||
supervisor.Spec.Arguments);
|
||||
Assert.Equal(Password, supervisor.PasswordWrittenToStdin);
|
||||
Assert.DoesNotContain(
|
||||
Password,
|
||||
string.Join(' ', supervisor.Spec.Arguments),
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
Password,
|
||||
SessionConfigComposer.Serialize(config.LastComposed.Document),
|
||||
StringComparison.Ordinal);
|
||||
|
||||
QueueStatusSource source = Assert.Single(statusSources.Created);
|
||||
source.Enqueue(Connected("s1"));
|
||||
source.Enqueue(EnteredWorld("s1", "+Acdream"));
|
||||
orchestrator.PollStatus();
|
||||
|
||||
LauncherSessionSnapshot inWorld = Assert.Single(orchestrator.GetSnapshot().Sessions);
|
||||
Assert.Equal(LauncherActivityState.InWorld, inWorld.State);
|
||||
Assert.Contains("+Acdream", inWorld.Status, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountGuiSelectDoesNotRequireACachedCharacterOrEmitASelector()
|
||||
{
|
||||
var config = new RecordingConfigService();
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
includeCharacter: false,
|
||||
configService: config,
|
||||
supervisorFactory: supervisors);
|
||||
|
||||
Assert.True(orchestrator.GetAccountLaunchCapability(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
LaunchMode.GuiSelect).IsAvailable);
|
||||
|
||||
LauncherSessionSnapshot launched = await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
characterName: null,
|
||||
LaunchMode.GuiSelect);
|
||||
|
||||
Assert.Null(launched.CharacterName);
|
||||
Assert.Equal(LaunchMode.GuiSelect, config.LastCharacter!.LaunchMode);
|
||||
Assert.Equal(string.Empty, config.LastCharacter.Name);
|
||||
string json = SessionConfigComposer.Serialize(config.LastComposed!.Document);
|
||||
Assert.DoesNotContain("\"character\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Password, json, StringComparison.Ordinal);
|
||||
Assert.Equal("gui-host", Assert.Single(supervisors.Created).Spec!.ExecutablePath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LaunchMode.Gui)]
|
||||
[InlineData(LaunchMode.Headless)]
|
||||
public async Task AccountLaunchWithoutACharacterOnlyAcceptsGuiSelect(LaunchMode mode)
|
||||
{
|
||||
var config = new RecordingConfigService();
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
includeCharacter: false,
|
||||
configService: config,
|
||||
supervisorFactory: supervisors);
|
||||
|
||||
await Assert.ThrowsAsync<LauncherOperationException>(() =>
|
||||
orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
characterName: null,
|
||||
mode));
|
||||
|
||||
Assert.Equal(0, config.PlayCallCount);
|
||||
Assert.Empty(supervisors.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeIsRefusedWhileTheAccountHasARunningLauncherActivity()
|
||||
{
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator();
|
||||
await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless);
|
||||
|
||||
LauncherCapability capability = orchestrator.GetProbeCapability(
|
||||
"Local ACE",
|
||||
"testaccount");
|
||||
|
||||
Assert.False(capability.IsAvailable);
|
||||
Assert.Contains("Stop", capability.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
await Assert.ThrowsAsync<LauncherOperationException>(() =>
|
||||
orchestrator.ProbeAsync("Local ACE", "testaccount"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LaunchIsRefusedWhileTheAccountProbeIsRunning()
|
||||
{
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator();
|
||||
await orchestrator.ProbeAsync("Local ACE", "testaccount");
|
||||
|
||||
LauncherCapability capability = orchestrator.GetAccountLaunchCapability(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
LaunchMode.Headless);
|
||||
|
||||
Assert.False(capability.IsAvailable);
|
||||
await Assert.ThrowsAsync<LauncherOperationException>(() =>
|
||||
orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentPlayReservationsAllowExactlyOneActivityPerAccount()
|
||||
{
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator();
|
||||
|
||||
Task<LauncherSessionSnapshot>[] attempts = Enumerable.Range(0, 2)
|
||||
.Select(_ => Task.Run(async () => await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless)))
|
||||
.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(attempts);
|
||||
}
|
||||
catch (LauncherOperationException)
|
||||
{
|
||||
// The losing reservation is the behavior under test.
|
||||
}
|
||||
|
||||
Task<LauncherSessionSnapshot> successful = Assert.Single(
|
||||
attempts,
|
||||
attempt => attempt.Status == TaskStatus.RanToCompletion);
|
||||
Task<LauncherSessionSnapshot> rejected = Assert.Single(attempts, attempt =>
|
||||
attempt.Exception?.GetBaseException() is LauncherOperationException);
|
||||
Assert.True(successful.IsCompletedSuccessfully);
|
||||
Assert.True(rejected.IsFaulted);
|
||||
Assert.Single(orchestrator.GetSnapshot().Sessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeUsesTheProbeShapeAndFoldsTheReportedRosterIntoTheStore()
|
||||
{
|
||||
var config = new RecordingConfigService();
|
||||
var statusSources = new QueueStatusSourceFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
includeCharacter: false,
|
||||
configService: config,
|
||||
statusSourceFactory: statusSources);
|
||||
|
||||
LauncherSessionSnapshot probe = await orchestrator.ProbeAsync(
|
||||
"Local ACE",
|
||||
"testaccount");
|
||||
|
||||
Assert.Equal(LauncherActivityKind.Probe, probe.Kind);
|
||||
Assert.Equal(0, config.PlayCallCount);
|
||||
Assert.Equal(1, config.ProbeCallCount);
|
||||
string json = SessionConfigComposer.Serialize(config.LastComposed!.Document);
|
||||
Assert.Contains("\"mode\": \"probe\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"character\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Password, json, StringComparison.Ordinal);
|
||||
|
||||
QueueStatusSource source = Assert.Single(statusSources.Created);
|
||||
source.Enqueue(new CharacterListStatusEvent
|
||||
{
|
||||
V = 1,
|
||||
E = "characterList",
|
||||
T = DateTimeOffset.UtcNow,
|
||||
SessionId = "s1",
|
||||
AccountName = "testaccount",
|
||||
SlotCount = 6,
|
||||
Characters =
|
||||
[
|
||||
new StatusCharacterEntry(0x5000000Au, "+Acdream", 0),
|
||||
new StatusCharacterEntry(0x5000000Bu, "+Second", 0),
|
||||
],
|
||||
});
|
||||
orchestrator.PollStatus();
|
||||
|
||||
LauncherAccountSnapshot account = Assert.Single(
|
||||
Assert.Single(orchestrator.GetSnapshot().Servers).Accounts);
|
||||
Assert.Equal(2, account.Characters.Count);
|
||||
Assert.Contains(account.Characters, character => character.Name == "+Acdream");
|
||||
Assert.Contains(account.Characters, character => character.Name == "+Second");
|
||||
|
||||
var reloaded = new LauncherProfileStore(
|
||||
Path.Combine(_paths.ConfigDirectory, "launcher-profiles.json"));
|
||||
Assert.True(reloaded.Load());
|
||||
Assert.Equal(
|
||||
2,
|
||||
reloaded.Document.Servers.Single().Accounts.Single().Characters.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinuxRejectsBothGraphicalModesBeforeCompositionOrSpawnWithSliceLExplanation()
|
||||
{
|
||||
var config = new RecordingConfigService();
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
platform: LinuxCapabilities(),
|
||||
configService: config,
|
||||
supervisorFactory: supervisors);
|
||||
|
||||
foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect })
|
||||
{
|
||||
LauncherOperationException exception = await Assert.ThrowsAsync<LauncherOperationException>(() =>
|
||||
orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
mode));
|
||||
Assert.Contains("Slice L", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("parked at L1", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.True(orchestrator.GetLaunchCapability(LaunchMode.Headless).IsAvailable);
|
||||
Assert.Equal(0, config.PlayCallCount);
|
||||
Assert.Empty(supervisors.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingCoDeployedHostsDisableActionsBeforeCompositionOrSpawn()
|
||||
{
|
||||
var config = new RecordingConfigService();
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
var executables = new LauncherExecutableSet(
|
||||
"missing-gui",
|
||||
"missing-headless",
|
||||
fileExists: _ => false);
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
configService: config,
|
||||
supervisorFactory: supervisors,
|
||||
executables: executables);
|
||||
|
||||
LauncherCapability gui = orchestrator.GetAccountLaunchCapability(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
LaunchMode.GuiSelect);
|
||||
LauncherCapability headless = orchestrator.GetAccountLaunchCapability(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
LaunchMode.Headless);
|
||||
LauncherCapability probe = orchestrator.GetProbeCapability(
|
||||
"Local ACE",
|
||||
"testaccount");
|
||||
|
||||
Assert.False(gui.IsAvailable);
|
||||
Assert.Contains("missing-gui", gui.Reason, StringComparison.Ordinal);
|
||||
Assert.False(headless.IsAvailable);
|
||||
Assert.Contains("missing-headless", headless.Reason, StringComparison.Ordinal);
|
||||
Assert.False(probe.IsAvailable);
|
||||
await Assert.ThrowsAsync<LauncherOperationException>(() =>
|
||||
orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless));
|
||||
Assert.Equal(0, config.PlayCallCount);
|
||||
Assert.Empty(supervisors.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessExitIsTerminalAndLateStatusCannotResurrectTheAccount()
|
||||
{
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
var statusSources = new QueueStatusSourceFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
supervisorFactory: supervisors,
|
||||
statusSourceFactory: statusSources);
|
||||
await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless);
|
||||
|
||||
Assert.Single(supervisors.Created).Exit(23);
|
||||
QueueStatusSource source = Assert.Single(statusSources.Created);
|
||||
source.Enqueue(Connected("s1"));
|
||||
source.Enqueue(EnteredWorld("s1", "+Acdream"));
|
||||
source.Enqueue(Exited("s1", 23, "host crash detail"));
|
||||
orchestrator.PollStatus();
|
||||
|
||||
LauncherSessionSnapshot session = Assert.Single(orchestrator.GetSnapshot().Sessions);
|
||||
Assert.Equal(LauncherActivityState.Exited, session.State);
|
||||
Assert.Equal(23, session.ExitCode);
|
||||
Assert.Contains("host crash detail", session.Status, StringComparison.Ordinal);
|
||||
Assert.True(orchestrator.GetProbeCapability("Local ACE", "testaccount").IsAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostExitReasonSurvivesTheLaterProcessExitCallback()
|
||||
{
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
var statusSources = new QueueStatusSourceFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
supervisorFactory: supervisors,
|
||||
statusSourceFactory: statusSources);
|
||||
await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless);
|
||||
|
||||
Assert.Single(statusSources.Created).Enqueue(
|
||||
Exited("s1", 0, "graceful host shutdown"));
|
||||
orchestrator.PollStatus();
|
||||
Assert.Single(supervisors.Created).Exit(0);
|
||||
|
||||
LauncherSessionSnapshot session = Assert.Single(orchestrator.GetSnapshot().Sessions);
|
||||
Assert.Equal(LauncherActivityState.Exited, session.State);
|
||||
Assert.Contains("graceful host shutdown", session.Status, StringComparison.Ordinal);
|
||||
Assert.True(orchestrator.GetAccountLaunchCapability(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
LaunchMode.Headless).IsAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartFailureIsVisibleButRedactsTheCredentialEverywhere()
|
||||
{
|
||||
var supervisors = new FakeSupervisorFactory(
|
||||
startExceptionFactory: password =>
|
||||
new IOException($"simulated pipe failure containing {password}"));
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
supervisorFactory: supervisors);
|
||||
|
||||
LauncherOperationException exception = await Assert.ThrowsAsync<LauncherOperationException>(
|
||||
() => orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless));
|
||||
|
||||
Assert.DoesNotContain(Password, exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("[redacted]", exception.Message, StringComparison.Ordinal);
|
||||
LauncherSessionSnapshot failed = Assert.Single(orchestrator.GetSnapshot().Sessions);
|
||||
Assert.Equal(LauncherActivityState.Failed, failed.State);
|
||||
Assert.DoesNotContain(Password, failed.Error ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreCancelledLaunchNeverComposesOrSpawnsAndEndsCancelled()
|
||||
{
|
||||
var config = new RecordingConfigService();
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
configService: config,
|
||||
supervisorFactory: supervisors);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless,
|
||||
cancellation.Token));
|
||||
|
||||
Assert.Equal(0, config.PlayCallCount);
|
||||
Assert.Empty(supervisors.Created);
|
||||
Assert.Equal(
|
||||
LauncherActivityState.Cancelled,
|
||||
Assert.Single(orchestrator.GetSnapshot().Sessions).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopRunsThroughTheSupervisorOffThreadAndMakesTheAccountProbeableAgain()
|
||||
{
|
||||
var supervisors = new FakeSupervisorFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
supervisorFactory: supervisors);
|
||||
LauncherSessionSnapshot launched = await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless);
|
||||
|
||||
await orchestrator.StopSessionAsync(
|
||||
launched.SessionId,
|
||||
TimeSpan.FromMilliseconds(10));
|
||||
|
||||
FakeSupervisor supervisor = Assert.Single(supervisors.Created);
|
||||
Assert.Equal(1, supervisor.StopCallCount);
|
||||
Assert.Equal(
|
||||
LauncherActivityState.Exited,
|
||||
Assert.Single(orchestrator.GetSnapshot().Sessions).State);
|
||||
Assert.True(orchestrator.GetProbeCapability(
|
||||
"Local ACE",
|
||||
"testaccount").IsAvailable);
|
||||
}
|
||||
|
||||
private LauncherOrchestrator CreateOrchestrator(
|
||||
bool includeCharacter = true,
|
||||
LauncherPlatformCapabilities? platform = null,
|
||||
ILauncherSessionConfigService? configService = null,
|
||||
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
||||
IStatusEventSourceFactory? statusSourceFactory = null,
|
||||
LauncherExecutableSet? executables = null)
|
||||
{
|
||||
string profilePath = Path.Combine(
|
||||
_paths.ConfigDirectory,
|
||||
"launcher-profiles.json");
|
||||
var store = new LauncherProfileStore(profilePath);
|
||||
store.Load();
|
||||
store.AddServer("Local ACE", "127.0.0.1", 9000);
|
||||
store.AddAccount("Local ACE", "testaccount", Password);
|
||||
if (includeCharacter)
|
||||
{
|
||||
store.AddCharacter(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
"0x5000000A",
|
||||
LaunchMode.GuiSelect,
|
||||
["ExamplePlugin"],
|
||||
["/vt start"]);
|
||||
}
|
||||
store.Save();
|
||||
|
||||
int nextSession = 0;
|
||||
var orchestrator = new LauncherOrchestrator(
|
||||
store,
|
||||
_paths,
|
||||
executables ?? new LauncherExecutableSet(
|
||||
"gui-host",
|
||||
"headless-host",
|
||||
fileExists: _ => true,
|
||||
hasUnixExecutePermission: _ => true),
|
||||
new LauncherInstallRecord("dats", "pak"),
|
||||
platform ?? WindowsCapabilities(),
|
||||
configService,
|
||||
supervisorFactory ?? new FakeSupervisorFactory(),
|
||||
statusSourceFactory ?? new QueueStatusSourceFactory(),
|
||||
() => $"s{Interlocked.Increment(ref nextSession)}");
|
||||
orchestrator.LoadProfiles();
|
||||
return orchestrator;
|
||||
}
|
||||
|
||||
private static LauncherPlatformCapabilities WindowsCapabilities() =>
|
||||
new(true, false, true, true, "Windows", null);
|
||||
|
||||
private static LauncherPlatformCapabilities LinuxCapabilities() =>
|
||||
new(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"Linux",
|
||||
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason);
|
||||
|
||||
private static ConnectedStatusEvent Connected(string sessionId) =>
|
||||
new()
|
||||
{
|
||||
V = 1,
|
||||
E = "connected",
|
||||
T = DateTimeOffset.UtcNow,
|
||||
SessionId = sessionId,
|
||||
};
|
||||
|
||||
private static EnteredWorldStatusEvent EnteredWorld(
|
||||
string sessionId,
|
||||
string characterName) =>
|
||||
new()
|
||||
{
|
||||
V = 1,
|
||||
E = "enteredWorld",
|
||||
T = DateTimeOffset.UtcNow,
|
||||
SessionId = sessionId,
|
||||
CharacterId = 0x5000000Au,
|
||||
CharacterName = characterName,
|
||||
};
|
||||
|
||||
private static ExitedStatusEvent Exited(
|
||||
string sessionId,
|
||||
int code,
|
||||
string reason) =>
|
||||
new()
|
||||
{
|
||||
V = 1,
|
||||
E = "exited",
|
||||
T = DateTimeOffset.UtcNow,
|
||||
SessionId = sessionId,
|
||||
Code = code,
|
||||
Reason = reason,
|
||||
};
|
||||
|
||||
private sealed class RecordingConfigService : ILauncherSessionConfigService
|
||||
{
|
||||
public int PlayCallCount { get; private set; }
|
||||
|
||||
public int ProbeCallCount { get; private set; }
|
||||
|
||||
public CharacterProfile? LastCharacter { get; private set; }
|
||||
|
||||
public string? LastAccountPassword { get; private set; }
|
||||
|
||||
public ComposedSessionConfig? LastComposed { get; private set; }
|
||||
|
||||
public ComposedSessionConfig ComposeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
CharacterProfile character,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId,
|
||||
int? loginCommandDelayMs = null)
|
||||
{
|
||||
PlayCallCount++;
|
||||
LastCharacter = character;
|
||||
LastAccountPassword = account.Password;
|
||||
LastComposed = SessionConfigComposer.Compose(
|
||||
server,
|
||||
account,
|
||||
character,
|
||||
install,
|
||||
paths,
|
||||
sessionId,
|
||||
loginCommandDelayMs);
|
||||
return LastComposed;
|
||||
}
|
||||
|
||||
public ComposedSessionConfig ComposeProbeAndWrite(
|
||||
ServerProfile server,
|
||||
AccountProfile account,
|
||||
LauncherInstallRecord install,
|
||||
ApplicationPathSet paths,
|
||||
string sessionId)
|
||||
{
|
||||
ProbeCallCount++;
|
||||
LastAccountPassword = account.Password;
|
||||
LastComposed = SessionConfigComposer.ComposeProbe(
|
||||
server,
|
||||
account,
|
||||
install,
|
||||
paths,
|
||||
sessionId);
|
||||
return LastComposed;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSupervisorFactory(
|
||||
Func<string?, Exception>? startExceptionFactory = null)
|
||||
: ILauncherProcessSupervisorFactory
|
||||
{
|
||||
public List<FakeSupervisor> Created { get; } = [];
|
||||
|
||||
public ILauncherProcessSupervisor Create()
|
||||
{
|
||||
var supervisor = new FakeSupervisor(startExceptionFactory);
|
||||
Created.Add(supervisor);
|
||||
return supervisor;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSupervisor(Func<string?, Exception>? startExceptionFactory)
|
||||
: ILauncherProcessSupervisor
|
||||
{
|
||||
public LauncherSessionState State { get; private set; } =
|
||||
LauncherSessionState.Starting;
|
||||
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
public LauncherProcessSpec? Spec { get; private set; }
|
||||
|
||||
public string? PasswordWrittenToStdin { get; private set; }
|
||||
|
||||
public int StopCallCount { get; private set; }
|
||||
|
||||
public event EventHandler<LauncherSessionState>? StateChanged;
|
||||
|
||||
public void Start(LauncherProcessSpec spec, string? password)
|
||||
{
|
||||
Spec = spec;
|
||||
PasswordWrittenToStdin = password;
|
||||
if (startExceptionFactory is not null)
|
||||
{
|
||||
throw startExceptionFactory(password);
|
||||
}
|
||||
|
||||
State = LauncherSessionState.Running;
|
||||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Stop(TimeSpan timeout)
|
||||
{
|
||||
StopCallCount++;
|
||||
State = LauncherSessionState.Exited;
|
||||
ExitCode = 0;
|
||||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Exit(int code)
|
||||
{
|
||||
State = LauncherSessionState.Exited;
|
||||
ExitCode = code;
|
||||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
|
||||
{
|
||||
public List<QueueStatusSource> Created { get; } = [];
|
||||
|
||||
public IStatusEventSource Create(string path)
|
||||
{
|
||||
var source = new QueueStatusSource();
|
||||
Created.Add(source);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QueueStatusSource : IStatusEventSource
|
||||
{
|
||||
private readonly Queue<StatusEvent> _events = [];
|
||||
|
||||
public void Enqueue(StatusEvent statusEvent) => _events.Enqueue(statusEvent);
|
||||
|
||||
public IReadOnlyList<StatusEvent> ReadNewEvents()
|
||||
{
|
||||
var result = new List<StatusEvent>();
|
||||
while (_events.TryDequeue(out StatusEvent? statusEvent))
|
||||
{
|
||||
if (statusEvent is not null)
|
||||
{
|
||||
result.Add(statusEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Profiles;
|
||||
|
||||
public sealed class LauncherProfileHardeningTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-launcher-profile-hardening-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
private readonly string _filePath;
|
||||
|
||||
public LauncherProfileHardeningTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_filePath = Path.Combine(_root, "launcher-profiles.json");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiFieldEditValidatesEverythingBeforeChangingAnything()
|
||||
{
|
||||
LauncherProfileStore store = CreatePopulatedStore();
|
||||
|
||||
Assert.Throws<LauncherProfileException>(() =>
|
||||
store.EditServer(
|
||||
"Local ACE",
|
||||
newName: "Partially renamed",
|
||||
newHost: "changed.example.test",
|
||||
newPort: 0));
|
||||
ServerProfile server = Assert.Single(store.Document.Servers);
|
||||
Assert.Equal("Local ACE", server.Name);
|
||||
Assert.Equal("127.0.0.1", server.Host);
|
||||
Assert.Equal(9000, server.Port);
|
||||
|
||||
Assert.Throws<LauncherProfileException>(() =>
|
||||
store.EditCharacter(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
newName: "+PartiallyRenamed",
|
||||
newId: "not-an-id"));
|
||||
CharacterProfile character = Assert.Single(server.Accounts.Single().Characters);
|
||||
Assert.Equal("+Acdream", character.Name);
|
||||
Assert.Equal("0x5000000A", character.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransactionRestoresTheExactDocumentWhenPersistenceFails()
|
||||
{
|
||||
Directory.CreateDirectory(_filePath);
|
||||
var store = new LauncherProfileStore(_filePath);
|
||||
store.Load();
|
||||
|
||||
Assert.ThrowsAny<Exception>(() =>
|
||||
store.ExecuteTransaction(() =>
|
||||
store.AddServer("Should roll back", "host", 9000)));
|
||||
|
||||
Assert.Empty(store.Document.Servers);
|
||||
Assert.False(File.Exists(_filePath + ".tmp"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransactionRestoresNestedCredentialAndSettingsOnMutationFailure()
|
||||
{
|
||||
LauncherProfileStore store = CreatePopulatedStore();
|
||||
string before = JsonSerializer.Serialize(store.Document);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
store.ExecuteTransaction(() =>
|
||||
{
|
||||
AccountProfile account = store.Document.Servers.Single().Accounts.Single();
|
||||
account.Password = "transient-secret";
|
||||
account.Characters.Single().Plugins.Add("Transient.Plugin");
|
||||
throw new InvalidOperationException("simulated mutation failure");
|
||||
}));
|
||||
|
||||
Assert.Equal(before, JsonSerializer.Serialize(store.Document));
|
||||
}
|
||||
|
||||
public static TheoryData<string> InvalidDocuments => new()
|
||||
{
|
||||
{ """{"version":1,"servers":null}""" },
|
||||
{ """{"version":1,"servers":[null]}""" },
|
||||
{ """{"version":1,"servers":[{"name":" ","host":"h","port":9000,"accounts":[]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":" ","port":9000,"accounts":[]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":0,"accounts":[]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[]},{"name":"s","host":"h2","port":9001,"accounts":[]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[]},{"account":"a","password":"p2","characters":[]}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":null,"characters":[]}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":null}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x00000000","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"invalid","plugins":[],"loginCommands":[]}]}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":null,"loginCommands":[]}]}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":["P","P"],"loginCommands":[]}]}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[" "]}]}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c","id":"0x50000002","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
|
||||
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c1","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c2","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidDocuments))]
|
||||
public void LoadRejectsSemanticallyInvalidDocuments(string json)
|
||||
{
|
||||
File.WriteAllText(_filePath, json);
|
||||
var store = new LauncherProfileStore(_filePath);
|
||||
|
||||
Assert.Throws<LauncherProfileException>(() => store.Load());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedLoadDoesNotReplaceAnAlreadyLoadedDocument()
|
||||
{
|
||||
LauncherProfileStore store = CreatePopulatedStore();
|
||||
File.WriteAllText(_filePath, """{"version":1,"servers":null}""");
|
||||
|
||||
Assert.Throws<LauncherProfileException>(() => store.Load());
|
||||
|
||||
Assert.Equal("Local ACE", Assert.Single(store.Document.Servers).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinuxLoadNormalizesAnExistingCredentialFileTo0600BeforeReading()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(_filePath, """{"version":1,"servers":[]}""");
|
||||
File.SetUnixFileMode(
|
||||
_filePath,
|
||||
UnixFileMode.UserRead
|
||||
| UnixFileMode.UserWrite
|
||||
| UnixFileMode.GroupRead
|
||||
| UnixFileMode.OtherRead);
|
||||
|
||||
var store = new LauncherProfileStore(_filePath);
|
||||
Assert.True(store.Load());
|
||||
|
||||
Assert.Equal(
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite,
|
||||
File.GetUnixFileMode(_filePath));
|
||||
}
|
||||
|
||||
private LauncherProfileStore CreatePopulatedStore()
|
||||
{
|
||||
var store = new LauncherProfileStore(_filePath);
|
||||
store.Load();
|
||||
store.AddServer("Local ACE", "127.0.0.1", 9000);
|
||||
store.AddAccount("Local ACE", "testaccount", "password");
|
||||
store.AddCharacter(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
"0x5000000A",
|
||||
LaunchMode.Headless,
|
||||
["ExamplePlugin"],
|
||||
["/vt start"]);
|
||||
store.Save();
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
|
@ -187,6 +187,60 @@ public sealed class LauncherProfileStoreTests : IDisposable
|
|||
Assert.Equal("0x5000000A", character.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEditAndRemoveCachedCharacterRoundTripsThroughTheCrudSurface()
|
||||
{
|
||||
var store = new LauncherProfileStore(_filePath);
|
||||
store.Load();
|
||||
store.AddServer("Local ACE", "127.0.0.1", 9000);
|
||||
store.AddAccount("Local ACE", "testaccount", "pw");
|
||||
|
||||
store.AddCharacter(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Manual",
|
||||
"0x5000000a");
|
||||
CharacterProfile character = Assert.Single(
|
||||
store.Document.Servers.Single().Accounts.Single().Characters);
|
||||
Assert.Equal("0x5000000A", character.Id);
|
||||
Assert.Equal(LaunchMode.GuiSelect, character.LaunchMode);
|
||||
|
||||
store.EditCharacter(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Manual",
|
||||
newName: "+Renamed",
|
||||
newId: "",
|
||||
launchMode: LaunchMode.Headless);
|
||||
character = Assert.Single(
|
||||
store.Document.Servers.Single().Accounts.Single().Characters);
|
||||
Assert.Equal("+Renamed", character.Name);
|
||||
Assert.Null(character.Id);
|
||||
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
|
||||
|
||||
store.RemoveCharacter("Local ACE", "testaccount", "+Renamed");
|
||||
Assert.Empty(store.Document.Servers.Single().Accounts.Single().Characters);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("5000000A")]
|
||||
[InlineData("0x00000000")]
|
||||
[InlineData("not-an-id")]
|
||||
public void AddCharacterRejectsAnAmbiguousOrInvalidId(string id)
|
||||
{
|
||||
var store = new LauncherProfileStore(_filePath);
|
||||
store.Load();
|
||||
store.AddServer("Local ACE", "127.0.0.1", 9000);
|
||||
store.AddAccount("Local ACE", "testaccount", "pw");
|
||||
|
||||
Assert.Throws<LauncherProfileException>(() =>
|
||||
store.AddCharacter(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Manual",
|
||||
id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullProfileWithServersAccountsAndCharactersRoundTripsThroughDisk()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -152,6 +152,66 @@ public sealed class RosterMergeTests
|
|||
Assert.Equal("+Acdream", character.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameNameRosterEntryCorrectsAWrongValidIdAndPreservesSettings()
|
||||
{
|
||||
LauncherProfileStore store = NewStoreWithServerAndAccount();
|
||||
store.AddCharacter(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
"0x50000001",
|
||||
LaunchMode.Headless,
|
||||
["ExamplePlugin"],
|
||||
["/vt start"]);
|
||||
|
||||
store.MergeRoster(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
|
||||
|
||||
CharacterProfile character = Assert.Single(
|
||||
store.Document.Servers.Single().Accounts.Single().Characters);
|
||||
Assert.Equal("0x5000000A", character.Id);
|
||||
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
|
||||
Assert.Equal(["ExamplePlugin"], character.Plugins);
|
||||
Assert.Equal(["/vt start"], character.LoginCommands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeMergeCollapsesCorrectIdAndSameNameDuplicates()
|
||||
{
|
||||
LauncherProfileStore store = NewStoreWithServerAndAccount();
|
||||
AccountProfile account = store.Document.Servers.Single().Accounts.Single();
|
||||
account.Characters.Add(new CharacterProfile
|
||||
{
|
||||
Id = "0x5000000A",
|
||||
Name = "+OldName",
|
||||
LaunchMode = LaunchMode.Headless,
|
||||
Plugins = ["Canonical.Plugin"],
|
||||
LoginCommands = ["/canonical"],
|
||||
});
|
||||
account.Characters.Add(new CharacterProfile
|
||||
{
|
||||
Id = "0x50000001",
|
||||
Name = "+Acdream",
|
||||
LaunchMode = LaunchMode.Gui,
|
||||
Plugins = ["Duplicate.Plugin"],
|
||||
LoginCommands = ["/duplicate"],
|
||||
});
|
||||
|
||||
store.MergeRoster(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
|
||||
|
||||
CharacterProfile character = Assert.Single(account.Characters);
|
||||
Assert.Equal("0x5000000A", character.Id);
|
||||
Assert.Equal("+Acdream", character.Name);
|
||||
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
|
||||
Assert.Equal(["Canonical.Plugin"], character.Plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeThrowsForUnknownServerOrAccount()
|
||||
{
|
||||
|
|
|
|||
25
tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj
Normal file
25
tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Launcher\AcDream.Launcher.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
172
tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs
Normal file
172
tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
using System.Diagnostics;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
||||
public sealed class LauncherProjectBoundaryTests
|
||||
{
|
||||
private static readonly string[] ExpectedAvaloniaPackages =
|
||||
[
|
||||
"Avalonia",
|
||||
"Avalonia.Desktop",
|
||||
"Avalonia.Themes.Fluent",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void LauncherReferencesOnlyLauncherCoreAndPinsOneAvaloniaVersion()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string projectPath = Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.Launcher",
|
||||
"AcDream.Launcher.csproj");
|
||||
XDocument project = XDocument.Load(projectPath);
|
||||
|
||||
string projectReference = Assert.Single(
|
||||
project.Descendants("ProjectReference")
|
||||
.Select(element => element.Attribute("Include")?.Value ?? string.Empty));
|
||||
Assert.EndsWith(
|
||||
"AcDream.Launcher.Core\\AcDream.Launcher.Core.csproj",
|
||||
projectReference,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
(string Name, string Version)[] packages = project
|
||||
.Descendants("PackageReference")
|
||||
.Select(element => (
|
||||
element.Attribute("Include")?.Value ?? string.Empty,
|
||||
element.Attribute("Version")?.Value ?? string.Empty))
|
||||
.OrderBy(package => package.Item1, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(ExpectedAvaloniaPackages, packages.Select(package => package.Name));
|
||||
Assert.All(packages, package => Assert.Equal("12.1.1", package.Version));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherAndItsTestsAreSolutionMembers()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
XDocument solution = XDocument.Load(Path.Combine(root, "AcDream.slnx"));
|
||||
string[] paths = solution.Descendants("Project")
|
||||
.Select(element => (element.Attribute("Path")?.Value ?? string.Empty)
|
||||
.Replace('\\', '/'))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("src/AcDream.Launcher/AcDream.Launcher.csproj", paths);
|
||||
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinuxPublishEvaluatesAsSelfContainedSingleFile()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string projectPath = Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.Launcher",
|
||||
"AcDream.Launcher.csproj");
|
||||
|
||||
Assert.Equal("true", EvaluateProperty(projectPath, "SelfContained"));
|
||||
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string markup = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.Launcher",
|
||||
"MainWindow.axaml"));
|
||||
string codeBehind = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.Launcher",
|
||||
"MainWindow.axaml.cs"));
|
||||
|
||||
Assert.Equal(3, Count(markup, "KeyboardNavigation.TabNavigation=\"Cycle\""));
|
||||
Assert.Equal(3, Count(markup, "KeyDown=\"OnModalKeyDown\""));
|
||||
Assert.True(Count(markup, "AutomationProperties.Name=") >= 13);
|
||||
Assert.True(Count(markup, "IsDefault=\"True\"") >= 3);
|
||||
Assert.True(Count(markup, "IsCancel=\"True\"") >= 3);
|
||||
Assert.Contains("ServerNameTextBox", markup, StringComparison.Ordinal);
|
||||
Assert.Contains("AccountNameTextBox", markup, StringComparison.Ordinal);
|
||||
Assert.Contains("CharacterNameTextBox", markup, StringComparison.Ordinal);
|
||||
Assert.Contains("FocusActiveModal", codeBehind, StringComparison.Ordinal);
|
||||
Assert.Contains("_focusBeforeModal", codeBehind, StringComparison.Ordinal);
|
||||
Assert.Contains("Key.Escape", codeBehind, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortabilityWorkflowBuildsTestsPublishesAndExecutesTheLauncher()
|
||||
{
|
||||
string workflow = File.ReadAllText(Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
".github",
|
||||
"workflows",
|
||||
"headless-portability.yml"));
|
||||
|
||||
Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("-r linux-x64", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
|
||||
workflow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("test -x \"$root/AcDream.App\"", workflow, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string EvaluateProperty(string projectPath, string property)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo("dotnet")
|
||||
{
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
startInfo.ArgumentList.Add("msbuild");
|
||||
startInfo.ArgumentList.Add(projectPath);
|
||||
startInfo.ArgumentList.Add("-nologo");
|
||||
startInfo.ArgumentList.Add("-property:RuntimeIdentifier=linux-x64");
|
||||
startInfo.ArgumentList.Add($"-getProperty:{property}");
|
||||
|
||||
using Process process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Could not start dotnet msbuild.");
|
||||
string output = process.StandardOutput.ReadToEnd();
|
||||
string error = process.StandardError.ReadToEnd();
|
||||
Assert.True(process.WaitForExit(30_000), "dotnet msbuild did not exit.");
|
||||
Assert.True(
|
||||
process.ExitCode == 0,
|
||||
$"dotnet msbuild exited {process.ExitCode}: {error}");
|
||||
return output.Trim();
|
||||
}
|
||||
|
||||
private static int Count(string text, string value) =>
|
||||
text.Split(value, StringSplitOptions.None).Length - 1;
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
|
||||
{
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
592
tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
Normal file
592
tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
||||
public sealed class LauncherWindowViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator();
|
||||
using var viewModel = new LauncherWindowViewModel(
|
||||
orchestrator,
|
||||
new ImmediateUiDispatcher());
|
||||
|
||||
viewModel.Initialize();
|
||||
|
||||
Assert.True(orchestrator.LoadCalled);
|
||||
LauncherTreeNodeViewModel server = Assert.Single(viewModel.Servers);
|
||||
Assert.Equal(LauncherTreeNodeKind.Server, server.Kind);
|
||||
LauncherTreeNodeViewModel account = Assert.Single(server.Children);
|
||||
Assert.Equal(LauncherTreeNodeKind.Account, account.Kind);
|
||||
LauncherTreeNodeViewModel character = Assert.Single(account.Children);
|
||||
Assert.Equal("+Acdream", character.DisplayName);
|
||||
Assert.Same(server, viewModel.SelectedNode);
|
||||
|
||||
LauncherSessionRowViewModel session = Assert.Single(viewModel.Sessions);
|
||||
Assert.Equal("Gui", session.Mode);
|
||||
Assert.Equal("Connected", session.State);
|
||||
Assert.True(session.IsActive);
|
||||
|
||||
Assert.True(viewModel.IsFirstRunRequired);
|
||||
Assert.Contains("LA9", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
||||
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
||||
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProfileCommandsExposeServerAccountCharacterCrudDialogsAndClearPasswords()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator();
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
|
||||
viewModel.AddServerCommand.Execute(null);
|
||||
Assert.Equal(ProfileEditorKind.AddServer, viewModel.EditorDialog.Kind);
|
||||
viewModel.EditorDialog.Name = "Remote ACE";
|
||||
viewModel.EditorDialog.Host = "ace.example.test";
|
||||
viewModel.EditorDialog.Port = "9001";
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal(("Remote ACE", "ace.example.test", 9001), orchestrator.AddedServer);
|
||||
|
||||
SelectServer(viewModel);
|
||||
viewModel.AddAccountCommand.Execute(null);
|
||||
Assert.Equal(ProfileEditorKind.AddAccount, viewModel.EditorDialog.Kind);
|
||||
viewModel.EditorDialog.Name = "second-account";
|
||||
viewModel.EditorDialog.Password = "one-use-secret";
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal(
|
||||
("Local ACE", "second-account", "one-use-secret"),
|
||||
orchestrator.AddedAccount);
|
||||
Assert.Equal(string.Empty, viewModel.EditorDialog.Password);
|
||||
|
||||
SelectAccount(viewModel);
|
||||
viewModel.AddCharacterCommand.Execute(null);
|
||||
Assert.Equal(ProfileEditorKind.AddCharacter, viewModel.EditorDialog.Kind);
|
||||
viewModel.EditorDialog.Name = "+Second";
|
||||
viewModel.EditorDialog.CharacterId = "0x5000000B";
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal(
|
||||
("Local ACE", "testaccount", "+Second", "0x5000000B"),
|
||||
orchestrator.AddedCharacter);
|
||||
|
||||
SelectCharacter(viewModel);
|
||||
viewModel.EditSelectedCommand.Execute(null);
|
||||
Assert.Equal(ProfileEditorKind.EditCharacter, viewModel.EditorDialog.Kind);
|
||||
viewModel.EditorDialog.Name = "+Renamed";
|
||||
viewModel.EditorDialog.CharacterId = "0x5000000C";
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal(
|
||||
("Local ACE", "testaccount", "+Acdream", "+Renamed", "0x5000000C"),
|
||||
orchestrator.EditedCharacter);
|
||||
|
||||
SelectCharacter(viewModel);
|
||||
viewModel.RemoveSelectedCommand.Execute(null);
|
||||
Assert.Equal(ProfileEditorKind.Remove, viewModel.EditorDialog.Kind);
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal(
|
||||
("Local ACE", "testaccount", "+Acdream"),
|
||||
orchestrator.RemovedCharacter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerAndAccountEditRemoveDialogsRouteEveryMutationThroughCore()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator();
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
|
||||
SelectServer(viewModel);
|
||||
viewModel.EditSelectedCommand.Execute(null);
|
||||
Assert.Equal(ProfileEditorKind.EditServer, viewModel.EditorDialog.Kind);
|
||||
Assert.Equal("127.0.0.1", viewModel.EditorDialog.Host);
|
||||
viewModel.EditorDialog.Name = "Renamed ACE";
|
||||
viewModel.EditorDialog.Host = "renamed.example.test";
|
||||
viewModel.EditorDialog.Port = "9010";
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal(
|
||||
("Local ACE", "Renamed ACE", "renamed.example.test", 9010),
|
||||
orchestrator.EditedServer);
|
||||
|
||||
SelectAccount(viewModel);
|
||||
viewModel.EditSelectedCommand.Execute(null);
|
||||
Assert.Equal(ProfileEditorKind.EditAccount, viewModel.EditorDialog.Kind);
|
||||
viewModel.EditorDialog.Name = "renamed-account";
|
||||
viewModel.EditorDialog.Password = "replacement-secret";
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal(
|
||||
("Local ACE", "testaccount", "renamed-account", "replacement-secret"),
|
||||
orchestrator.EditedAccount);
|
||||
Assert.Equal(string.Empty, viewModel.EditorDialog.Password);
|
||||
|
||||
SelectAccount(viewModel);
|
||||
viewModel.RemoveSelectedCommand.Execute(null);
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal(("Local ACE", "testaccount"), orchestrator.RemovedAccount);
|
||||
|
||||
SelectServer(viewModel);
|
||||
viewModel.RemoveSelectedCommand.Execute(null);
|
||||
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
||||
Assert.Equal("Local ACE", orchestrator.RemovedServer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CharacterSettingsAndLaunchActionsPreserveTheirTypedSemantics()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator();
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
SelectCharacter(viewModel);
|
||||
|
||||
viewModel.CharacterLaunchMode = LaunchMode.Headless;
|
||||
viewModel.CharacterPluginsText = "Plugin.One\nPlugin.Two\nPlugin.One";
|
||||
viewModel.CharacterLoginCommandsText = " /tell someone, hi \n/vt start\n/tell someone, hi";
|
||||
viewModel.SaveCharacterSettingsCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(orchestrator.SettingsUpdate);
|
||||
Assert.Equal(LaunchMode.Headless, orchestrator.SettingsUpdate.Value.Mode);
|
||||
Assert.Equal(["Plugin.One", "Plugin.Two"], orchestrator.SettingsUpdate.Value.Plugins);
|
||||
Assert.Equal(
|
||||
["/tell someone, hi", "/vt start", "/tell someone, hi"],
|
||||
orchestrator.SettingsUpdate.Value.Commands);
|
||||
|
||||
await viewModel.LaunchHeadlessCommand.ExecuteAsync();
|
||||
Assert.Equal(
|
||||
("Local ACE", "testaccount", "+Acdream", LaunchMode.Headless),
|
||||
orchestrator.LaunchRequest);
|
||||
Assert.Equal("Headless session started for +Acdream.", viewModel.OperationStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountGuiSelectWorksWithoutAnyCachedCharacter()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
IncludeCharacter = false,
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
SelectAccount(viewModel);
|
||||
|
||||
Assert.Empty(viewModel.SelectedNode!.Children);
|
||||
Assert.True(viewModel.CanLaunchAccountGuiSelect);
|
||||
await viewModel.LaunchAccountGuiSelectCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal(
|
||||
("Local ACE", "testaccount", (string?)null, LaunchMode.GuiSelect),
|
||||
orchestrator.LaunchRequest);
|
||||
Assert.Equal(
|
||||
"Character-select session started for testaccount.",
|
||||
viewModel.OperationStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeUsesTheSelectedAccountAndRunningAccountDisablesIt()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator();
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
SelectAccount(viewModel);
|
||||
|
||||
Assert.True(viewModel.CanProbe);
|
||||
await viewModel.RefreshCharactersCommand.ExecuteAsync();
|
||||
Assert.Equal(("Local ACE", "testaccount"), orchestrator.ProbeRequest);
|
||||
|
||||
orchestrator.ProbeCapability = LauncherCapability.Unavailable(
|
||||
"Stop the active session before refreshing this account.");
|
||||
orchestrator.RaiseStateChanged();
|
||||
|
||||
SelectAccount(viewModel);
|
||||
Assert.False(viewModel.CanProbe);
|
||||
Assert.Contains("Stop", viewModel.ProbeDisabledReason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinuxKeepsLauncherAndHeadlessAvailableButExplainsDisabledGuiModes()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Platform = LinuxPlatform(),
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
SelectCharacter(viewModel);
|
||||
|
||||
Assert.True(viewModel.ShowLinuxGraphicalNotice);
|
||||
Assert.False(viewModel.CanLaunchGui);
|
||||
Assert.False(viewModel.CanLaunchGuiSelect);
|
||||
Assert.True(viewModel.CanLaunchHeadless);
|
||||
Assert.Equal(
|
||||
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason,
|
||||
viewModel.LinuxGraphicalNotice);
|
||||
Assert.Contains("Slice L", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
||||
Assert.Contains("parked at L1", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingCoDeployedHostReasonIsVisibleForCharacterActions()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
AccountLaunchCapability = LauncherCapability.Unavailable(
|
||||
"The co-deployed host is missing; reinstall or update the client."),
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
SelectCharacter(viewModel);
|
||||
|
||||
Assert.False(viewModel.CanLaunchGui);
|
||||
Assert.False(viewModel.CanLaunchHeadless);
|
||||
Assert.True(viewModel.ShowGuiLaunchDisabledReason);
|
||||
Assert.True(viewModel.ShowHeadlessLaunchDisabledReason);
|
||||
Assert.Contains("missing", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
||||
Assert.Contains("missing", viewModel.HeadlessLaunchDisabledReason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LaunchErrorAndCancellationBecomeSafeVisibleState()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
LaunchHandler = _ => throw new LauncherOperationException("spawn failed safely"),
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
SelectCharacter(viewModel);
|
||||
|
||||
await viewModel.LaunchGuiCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal("spawn failed safely", viewModel.LastError);
|
||||
Assert.Equal("Operation failed.", viewModel.OperationStatus);
|
||||
Assert.False(viewModel.IsBusy);
|
||||
|
||||
orchestrator.LaunchHandler = async cancellationToken =>
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return FakeLauncherOrchestrator.CreateSession();
|
||||
};
|
||||
Task launch = viewModel.LaunchGuiCommand.ExecuteAsync();
|
||||
Assert.True(viewModel.IsBusy);
|
||||
Assert.True(viewModel.CancelOperationCommand.CanExecute(null));
|
||||
viewModel.CancelOperationCommand.Execute(null);
|
||||
await launch;
|
||||
|
||||
Assert.Equal("Operation cancelled.", viewModel.OperationStatus);
|
||||
Assert.False(viewModel.HasError);
|
||||
Assert.False(viewModel.IsBusy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProfileDialogRedactsARejectedCredentialAndClearsItOnClose()
|
||||
{
|
||||
var dialog = new ProfileEditorDialogViewModel();
|
||||
dialog.Open(
|
||||
ProfileEditorKind.AddAccount,
|
||||
"Add account",
|
||||
candidate => throw new InvalidOperationException(
|
||||
$"rejected {candidate.Password}"));
|
||||
dialog.Password = "do-not-display";
|
||||
|
||||
dialog.SubmitCommand.Execute(null);
|
||||
|
||||
Assert.True(dialog.IsOpen);
|
||||
Assert.DoesNotContain("do-not-display", dialog.Error ?? string.Empty, StringComparison.Ordinal);
|
||||
Assert.Contains("[redacted]", dialog.Error ?? string.Empty, StringComparison.Ordinal);
|
||||
dialog.CancelCommand.Execute(null);
|
||||
Assert.Equal(string.Empty, dialog.Password);
|
||||
Assert.False(dialog.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModalShellsBlockBackgroundCommandsAndAreMutuallyExclusive()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator();
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
|
||||
Assert.True(viewModel.AddServerCommand.CanExecute(null));
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
Assert.True(viewModel.IsModalOpen);
|
||||
Assert.False(viewModel.AddServerCommand.CanExecute(null));
|
||||
Assert.False(viewModel.UpdatePromptShell.OpenCommand.CanExecute(null));
|
||||
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
|
||||
|
||||
// ICommand.Execute cannot bypass the modal gate.
|
||||
viewModel.AddServerCommand.Execute(null);
|
||||
Assert.False(viewModel.EditorDialog.IsOpen);
|
||||
Assert.Null(orchestrator.AddedServer);
|
||||
|
||||
viewModel.CloseActiveModal();
|
||||
Assert.False(viewModel.IsModalOpen);
|
||||
Assert.True(viewModel.AddServerCommand.CanExecute(null));
|
||||
|
||||
viewModel.AddServerCommand.Execute(null);
|
||||
Assert.True(viewModel.EditorDialog.IsOpen);
|
||||
Assert.False(viewModel.FirstRunWizardShell.OpenCommand.CanExecute(null));
|
||||
Assert.False(viewModel.ClearFinishedSessionsCommand.CanExecute(null));
|
||||
viewModel.CloseActiveModal();
|
||||
Assert.False(viewModel.EditorDialog.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator();
|
||||
using var viewModel = CreateInitialized(orchestrator);
|
||||
|
||||
LauncherSessionRowViewModel session = Assert.Single(viewModel.Sessions);
|
||||
await session.StopCommand.ExecuteAsync();
|
||||
Assert.Equal("session-1", orchestrator.StoppedSessionId);
|
||||
|
||||
orchestrator.Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly.");
|
||||
orchestrator.RaiseStateChanged();
|
||||
Assert.True(viewModel.ClearFinishedSessionsCommand.CanExecute(null));
|
||||
viewModel.ClearFinishedSessionsCommand.Execute(null);
|
||||
Assert.True(orchestrator.ClearCalled);
|
||||
}
|
||||
|
||||
private static LauncherWindowViewModel CreateInitialized(
|
||||
FakeLauncherOrchestrator orchestrator)
|
||||
{
|
||||
var viewModel = new LauncherWindowViewModel(
|
||||
orchestrator,
|
||||
new ImmediateUiDispatcher());
|
||||
viewModel.Initialize();
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
private static void SelectServer(LauncherWindowViewModel viewModel) =>
|
||||
viewModel.SelectedNode = Assert.Single(viewModel.Servers);
|
||||
|
||||
private static void SelectAccount(LauncherWindowViewModel viewModel) =>
|
||||
viewModel.SelectedNode = Assert.Single(Assert.Single(viewModel.Servers).Children);
|
||||
|
||||
private static void SelectCharacter(LauncherWindowViewModel viewModel) =>
|
||||
viewModel.SelectedNode = Assert.Single(
|
||||
Assert.Single(Assert.Single(viewModel.Servers).Children).Children);
|
||||
|
||||
private static LauncherPlatformCapabilities LinuxPlatform() => new(
|
||||
IsWindows: false,
|
||||
IsLinux: true,
|
||||
CanRunHeadless: true,
|
||||
CanLaunchGraphicalClient: false,
|
||||
PlatformName: "Linux",
|
||||
GraphicalLaunchDisabledReason:
|
||||
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason);
|
||||
|
||||
private sealed class FakeLauncherOrchestrator : ILauncherOrchestrator
|
||||
{
|
||||
public event EventHandler? StateChanged;
|
||||
|
||||
public bool LoadCalled { get; private set; }
|
||||
|
||||
public bool ClearCalled { get; private set; }
|
||||
|
||||
public LauncherPlatformCapabilities Platform { get; init; } = new(
|
||||
IsWindows: true,
|
||||
IsLinux: false,
|
||||
CanRunHeadless: true,
|
||||
CanLaunchGraphicalClient: true,
|
||||
PlatformName: "Windows",
|
||||
GraphicalLaunchDisabledReason: null);
|
||||
|
||||
public LauncherCapability ProbeCapability { get; set; } = LauncherCapability.Available;
|
||||
|
||||
public LauncherCapability? AccountLaunchCapability { get; set; }
|
||||
|
||||
public bool IncludeCharacter { get; init; } = true;
|
||||
|
||||
public LauncherSessionSnapshot Session { get; set; } = CreateSession();
|
||||
|
||||
public Func<CancellationToken, Task<LauncherSessionSnapshot>>? LaunchHandler { get; set; }
|
||||
|
||||
public (string Name, string Host, int Port)? AddedServer { get; private set; }
|
||||
|
||||
public (string Name, string NewName, string NewHost, int NewPort)? EditedServer { get; private set; }
|
||||
|
||||
public string? RemovedServer { get; private set; }
|
||||
|
||||
public (string Server, string Account, string Password)? AddedAccount { get; private set; }
|
||||
|
||||
public (string Server, string Account, string NewAccount, string? Password)? EditedAccount { get; private set; }
|
||||
|
||||
public (string Server, string Account)? RemovedAccount { get; private set; }
|
||||
|
||||
public (string Server, string Account, string Character, string? Id)? AddedCharacter { get; private set; }
|
||||
|
||||
public (string Server, string Account, string Character, string NewName, string? Id)? EditedCharacter { get; private set; }
|
||||
|
||||
public (string Server, string Account, string Character)? RemovedCharacter { get; private set; }
|
||||
|
||||
public (LaunchMode Mode, IReadOnlyList<string> Plugins, IReadOnlyList<string> Commands)? SettingsUpdate { get; private set; }
|
||||
|
||||
public (string Server, string Account, string? Character, LaunchMode Mode)? LaunchRequest { get; private set; }
|
||||
|
||||
public (string Server, string Account)? ProbeRequest { get; private set; }
|
||||
|
||||
public string? StoppedSessionId { get; private set; }
|
||||
|
||||
public void LoadProfiles() => LoadCalled = true;
|
||||
|
||||
public LauncherStateSnapshot GetSnapshot() => new(
|
||||
[CreateServerSnapshot()],
|
||||
[Session],
|
||||
Platform,
|
||||
IsInstallationReady: false,
|
||||
InstallationStatus: "No installed client is configured.");
|
||||
|
||||
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
||||
Platform.ForLaunchMode(mode);
|
||||
|
||||
public LauncherCapability GetAccountLaunchCapability(
|
||||
string serverName,
|
||||
string accountName,
|
||||
LaunchMode mode) =>
|
||||
AccountLaunchCapability ?? GetLaunchCapability(mode);
|
||||
|
||||
public LauncherCapability GetProbeCapability(string serverName, string accountName) =>
|
||||
ProbeCapability;
|
||||
|
||||
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
||||
{
|
||||
}
|
||||
|
||||
public void AddServer(string name, string host, int port) =>
|
||||
AddedServer = (name, host, port);
|
||||
|
||||
public void EditServer(string name, string newName, string newHost, int newPort) =>
|
||||
EditedServer = (name, newName, newHost, newPort);
|
||||
|
||||
public void RemoveServer(string name) => RemovedServer = name;
|
||||
|
||||
public void AddAccount(string serverName, string accountName, string password) =>
|
||||
AddedAccount = (serverName, accountName, password);
|
||||
|
||||
public void EditAccount(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string newAccountName,
|
||||
string? newPassword) =>
|
||||
EditedAccount = (serverName, accountName, newAccountName, newPassword);
|
||||
|
||||
public void RemoveAccount(string serverName, string accountName) =>
|
||||
RemovedAccount = (serverName, accountName);
|
||||
|
||||
public void AddCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string? characterId) =>
|
||||
AddedCharacter = (serverName, accountName, characterName, characterId);
|
||||
|
||||
public void EditCharacterIdentity(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string newCharacterName,
|
||||
string? newCharacterId) =>
|
||||
EditedCharacter = (
|
||||
serverName,
|
||||
accountName,
|
||||
characterName,
|
||||
newCharacterName,
|
||||
newCharacterId);
|
||||
|
||||
public void UpdateCharacterSettings(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
LaunchMode launchMode,
|
||||
IReadOnlyList<string> plugins,
|
||||
IReadOnlyList<string> loginCommands) =>
|
||||
SettingsUpdate = (launchMode, plugins, loginCommands);
|
||||
|
||||
public void RemoveCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName) =>
|
||||
RemovedCharacter = (serverName, accountName, characterName);
|
||||
|
||||
public Task<LauncherSessionSnapshot> LaunchAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string? characterName,
|
||||
LaunchMode mode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LaunchRequest = (serverName, accountName, characterName, mode);
|
||||
return LaunchHandler?.Invoke(cancellationToken) ?? Task.FromResult(Session);
|
||||
}
|
||||
|
||||
public Task<LauncherSessionSnapshot> ProbeAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ProbeRequest = (serverName, accountName);
|
||||
return Task.FromResult(Session with { Kind = LauncherActivityKind.Probe });
|
||||
}
|
||||
|
||||
public Task StopSessionAsync(
|
||||
string sessionId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
StoppedSessionId = sessionId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void PollStatus()
|
||||
{
|
||||
}
|
||||
|
||||
public void ClearFinishedSessions() => ClearCalled = true;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public void RaiseStateChanged() => StateChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
public static LauncherSessionSnapshot CreateSession(
|
||||
LauncherActivityState state = LauncherActivityState.Connected,
|
||||
string status = "Connected.") => new(
|
||||
"session-1",
|
||||
LauncherActivityKind.Play,
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Gui,
|
||||
state,
|
||||
status,
|
||||
ExitCode: state == LauncherActivityState.Exited ? 0 : null,
|
||||
Error: null,
|
||||
CreatedAt: DateTimeOffset.UnixEpoch);
|
||||
|
||||
private LauncherServerSnapshot CreateServerSnapshot() => new(
|
||||
"Local ACE",
|
||||
"127.0.0.1",
|
||||
9000,
|
||||
[
|
||||
new LauncherAccountSnapshot(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
IncludeCharacter
|
||||
?
|
||||
[
|
||||
new LauncherCharacterSnapshot(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
"0x5000000A",
|
||||
LaunchMode.GuiSelect,
|
||||
["Existing.Plugin"],
|
||||
["/tell someone, ready"],
|
||||
HasRunningSession: true,
|
||||
SessionStatus: "Connected."),
|
||||
]
|
||||
: [],
|
||||
HasRunningActivity: true,
|
||||
ActivityStatus: "Connected."),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue