feat(launcher): Campaign LA add Avalonia desktop shell
This commit is contained in:
parent
6c4cd2bbc6
commit
d0a9c65d85
31 changed files with 4831 additions and 16 deletions
|
|
@ -0,0 +1,502 @@
|
|||
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 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 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 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)
|
||||
{
|
||||
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,
|
||||
new LauncherExecutableSet("gui-host", "headless-host"),
|
||||
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 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue