fix(launcher): close LA4 review findings
This commit is contained in:
parent
d0a9c65d85
commit
10a712d66b
19 changed files with 1631 additions and 134 deletions
|
|
@ -0,0 +1,67 @@
|
|||
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);
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -106,6 +106,59 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
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()
|
||||
{
|
||||
|
|
@ -126,6 +179,58 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
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()
|
||||
{
|
||||
|
|
@ -206,6 +311,103 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
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()
|
||||
{
|
||||
|
|
@ -286,7 +488,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
LauncherPlatformCapabilities? platform = null,
|
||||
ILauncherSessionConfigService? configService = null,
|
||||
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
||||
IStatusEventSourceFactory? statusSourceFactory = null)
|
||||
IStatusEventSourceFactory? statusSourceFactory = null,
|
||||
LauncherExecutableSet? executables = null)
|
||||
{
|
||||
string profilePath = Path.Combine(
|
||||
_paths.ConfigDirectory,
|
||||
|
|
@ -312,7 +515,10 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
var orchestrator = new LauncherOrchestrator(
|
||||
store,
|
||||
_paths,
|
||||
new LauncherExecutableSet("gui-host", "headless-host"),
|
||||
executables ?? new LauncherExecutableSet(
|
||||
"gui-host",
|
||||
"headless-host",
|
||||
fileExists: _ => true),
|
||||
new LauncherInstallRecord("dats", "pak"),
|
||||
platform ?? WindowsCapabilities(),
|
||||
configService,
|
||||
|
|
@ -357,6 +563,20 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
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; }
|
||||
|
|
@ -462,6 +682,13 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Exit(int code)
|
||||
{
|
||||
State = LauncherSessionState.Exited;
|
||||
ExitCode = code;
|
||||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue