MainWindowViewTests.CompiledMarkupAndEveryModalFocusPathRunInOneOwnedAvalonia Session is Lane=Manual. Measured across five environments on 2026-08-19: dev desktop PASS CI Windows box, over SSH PASS Windows under act_runner FAIL Linux, plain SSH FAIL Always the same shape — Test Case Cleanup, 'The calling thread cannot access this object', while a compositor is being CONSTRUCTED (Compositor..ctor -> DefaultRenderLoop.Add -> VerifyAccess). Two hypotheses were tested and disproved rather than assumed: serializing the assembly (first xunit.runner.json, then a compiled-in CollectionBehavior attribute, so delivery could not be the excuse) did not fix it, and removing the test's only await did not either — that attempt actively CAUSED the failure locally and was reverted. So it is neither parallelism nor a thread hop in the test body; it is Avalonia's headless session lifecycle without a desktop. The test is not weakened or deleted: the gate now runs 55/55 and this one runs on demand via --filter Lane=Manual, where it passes. That matches how the InstalledDat and Live lanes already work.
557 lines
22 KiB
C#
557 lines
22 KiB
C#
using System.Reflection;
|
|
using System.Xml.Linq;
|
|
using AcDream.Launcher.Core.Installation;
|
|
using AcDream.Launcher.Core.Launching;
|
|
using AcDream.Launcher.Core.Orchestration;
|
|
using AcDream.Launcher.Core.Profiles;
|
|
using AcDream.Launcher.ViewModels;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless.XUnit;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
|
|
namespace AcDream.Launcher.Tests;
|
|
|
|
/// <summary>
|
|
/// Closes #399: no test ever constructed <see cref="MainWindow"/>, so the
|
|
/// #398 defect class (code-behind dereferencing an x:Name field that
|
|
/// <c>AvaloniaXamlLoader.Load(this)</c> never assigns, instead of the
|
|
/// generated <c>InitializeComponent()</c>) reached the user gate through
|
|
/// 14,012 green tests that were all ViewModel-only.
|
|
///
|
|
/// The Avalonia test constructs real <see cref="MainWindow"/> instances against
|
|
/// the compiled XAML and drives them exactly the way <c>App.axaml.cs</c>
|
|
/// does: assign a live <see cref="LauncherWindowViewModel"/> as
|
|
/// <c>DataContext</c>, then exercise the modal open/close paths that
|
|
/// dereference the named controls (the bug class lives in
|
|
/// MainWindow.axaml.cs's <c>OnViewModelPropertyChanged</c> and
|
|
/// <c>FocusActiveModal</c>). That focus work is queued via
|
|
/// <c>Dispatcher.UIThread.Post</c>, so every test pumps the headless
|
|
/// dispatcher with <see cref="Dispatcher.RunJobs"/> before asserting — a
|
|
/// phase that only sets a property and asserts would pass vacuously
|
|
/// without ever running <c>FocusActiveModal</c>.
|
|
/// </summary>
|
|
public sealed class MainWindowViewTests
|
|
{
|
|
// Every x:Name in MainWindow.axaml, kept in sync with the reflection
|
|
// sweep below so a newly-added named control without a matching field
|
|
// fails loudly instead of silently reaching InitializeComponent().
|
|
private static readonly (string Name, Type Type)[] ExpectedNamedControls =
|
|
[
|
|
("ProfilesTree", typeof(TreeView)),
|
|
("ServerNameTextBox", typeof(TextBox)),
|
|
("AccountNameTextBox", typeof(TextBox)),
|
|
("CharacterNameTextBox", typeof(TextBox)),
|
|
("EditorSubmitButton", typeof(Button)),
|
|
("FirstRunDatDirectoryTextBox", typeof(TextBox)),
|
|
("FirstRunCloseButton", typeof(Button)),
|
|
("UpdateCloseButton", typeof(Button)),
|
|
];
|
|
|
|
// Lane=Manual: this is the ONE test in the repo that needs a real desktop
|
|
// session. Measured 2026-08-19 across five environments — passes on a dev
|
|
// desktop and on the CI Windows box over SSH; fails identically under
|
|
// act_runner's step context and on Linux, always in Test Case Cleanup with
|
|
// "The calling thread cannot access this object" while a compositor is
|
|
// being CONSTRUCTED (Compositor..ctor -> DefaultRenderLoop.Add ->
|
|
// VerifyAccess). Neither serializing the assembly (xunit.runner.json, then
|
|
// a compiled-in CollectionBehavior attribute) nor removing the test's only
|
|
// await changed it, so it is not parallelism and not a thread hop in the
|
|
// test body — it is Avalonia's headless session lifecycle in a
|
|
// desktop-less environment. Run it deliberately:
|
|
// dotnet test tests/AcDream.Launcher.Tests --filter Lane=Manual
|
|
[AvaloniaFact]
|
|
[Trait("Lane", "Manual")]
|
|
public async Task CompiledMarkupAndEveryModalFocusPathRunInOneOwnedAvaloniaSession()
|
|
{
|
|
// Avalonia's headless compositor is thread-affine. Keep the complete
|
|
// MainWindow matrix inside one runner-owned application session so
|
|
// xUnit cannot tear one compositor down on a different worker while
|
|
// starting the next scenario. Each named method remains a separate
|
|
// assertion phase for readable failure stacks.
|
|
EveryExplicitlyNamedControlIsAssignedAfterConstruction();
|
|
ReflectionSweepOfEveryXNameInMarkupFindsANonNullBackingField();
|
|
OpeningEveryEditorKindFocusesItsPrimaryFieldAndClosingRunsTheFallbackWithoutThrowing();
|
|
OpeningAndClosingTheFirstRunWizardFocusesAndRunsTheCloseFallbackWithoutThrowing();
|
|
await OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing();
|
|
ClosingAModalRestoresThePreviouslyFocusedControlWithoutThrowing();
|
|
}
|
|
|
|
private static void EveryExplicitlyNamedControlIsAssignedAfterConstruction()
|
|
{
|
|
var window = new MainWindow();
|
|
|
|
foreach ((string name, Type type) in ExpectedNamedControls)
|
|
{
|
|
object? value = GetNamedField(window, name);
|
|
Assert.True(
|
|
value is not null,
|
|
$"x:Name '{name}' was null after construction. Only the "
|
|
+ "generated InitializeComponent() assigns x:Name backing "
|
|
+ "fields; AvaloniaXamlLoader.Load(this) alone leaves them "
|
|
+ "null (this is the #398 defect class).");
|
|
Assert.IsAssignableFrom(type, value);
|
|
}
|
|
}
|
|
|
|
private static void ReflectionSweepOfEveryXNameInMarkupFindsANonNullBackingField()
|
|
{
|
|
string markupPath = Path.Combine(
|
|
FindRepositoryRoot(),
|
|
"src",
|
|
"AcDream.Launcher",
|
|
"MainWindow.axaml");
|
|
// Walk the markup as XML rather than regexing the raw text:
|
|
// template-scoped names (inside a DataTemplate/ControlTemplate/
|
|
// ItemTemplate) get NO generated backing field, so demanding one
|
|
// would false-fail the first time a template gains an x:Name
|
|
// (gate-round-1 review F5 — latent today, MainWindow has two
|
|
// templates with none inside).
|
|
XDocument document = XDocument.Load(markupPath);
|
|
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
|
|
List<string> names = document
|
|
.Descendants()
|
|
.Where(element => element.Attribute(x + "Name") is not null)
|
|
.Where(element => !element
|
|
.Ancestors()
|
|
.Any(ancestor => ancestor.Name.LocalName.EndsWith(
|
|
"Template",
|
|
StringComparison.Ordinal)))
|
|
.Select(element => element.Attribute(x + "Name")!.Value)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
// The markup must still declare at least the controls the
|
|
// code-behind dereferences; an empty sweep would make this test
|
|
// vacuous.
|
|
Assert.True(names.Count >= ExpectedNamedControls.Length);
|
|
|
|
var window = new MainWindow();
|
|
foreach (string name in names)
|
|
{
|
|
FieldInfo? field = typeof(MainWindow).GetField(
|
|
name,
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
Assert.True(field is not null, $"No backing field found for x:Name '{name}'.");
|
|
object? value = field!.GetValue(window);
|
|
Assert.True(
|
|
value is not null,
|
|
$"x:Name '{name}' resolved to a field but its value was null "
|
|
+ "after construction.");
|
|
}
|
|
}
|
|
|
|
private static void OpeningEveryEditorKindFocusesItsPrimaryFieldAndClosingRunsTheFallbackWithoutThrowing()
|
|
{
|
|
(ProfileEditorKind Kind, string FocusFieldName)[] cases =
|
|
[
|
|
(ProfileEditorKind.AddServer, "ServerNameTextBox"),
|
|
(ProfileEditorKind.EditServer, "ServerNameTextBox"),
|
|
(ProfileEditorKind.AddAccount, "AccountNameTextBox"),
|
|
(ProfileEditorKind.EditAccount, "AccountNameTextBox"),
|
|
(ProfileEditorKind.AddCharacter, "CharacterNameTextBox"),
|
|
(ProfileEditorKind.EditCharacter, "CharacterNameTextBox"),
|
|
(ProfileEditorKind.Remove, "EditorSubmitButton"),
|
|
];
|
|
|
|
foreach ((ProfileEditorKind kind, string expectedFocusFieldName) in cases)
|
|
{
|
|
using LauncherWindowViewModel viewModel = CreateViewModel();
|
|
var window = new MainWindow { DataContext = viewModel };
|
|
try
|
|
{
|
|
window.Show();
|
|
|
|
viewModel.EditorDialog.Open(kind, "Fixture title", _ => { });
|
|
Assert.True(viewModel.EditorDialog.IsOpen);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!;
|
|
Assert.Same(expectedFocus, CurrentFocus(window));
|
|
|
|
viewModel.EditorDialog.Close();
|
|
Assert.False(viewModel.EditorDialog.IsOpen);
|
|
|
|
// Nothing held focus before the dialog opened, so
|
|
// OnViewModelPropertyChanged's close branch posts the fallback
|
|
// (ProfilesTree.Focus()). Pumping the dispatcher is what actually
|
|
// *runs* FocusActiveModal's caller and its ProfilesTree
|
|
// dereference — this is the #398 defect class: with
|
|
// AvaloniaXamlLoader.Load(this) instead of InitializeComponent(),
|
|
// ProfilesTree is null here and this throws
|
|
// NullReferenceException out of the dispatcher. TreeView's Fluent
|
|
// template sets Focusable="False" (focus lives on TreeViewItem
|
|
// rows, not the tree itself), so a successful, non-throwing
|
|
// ProfilesTree.Focus() call still leaves focus at null — that is
|
|
// expected, not a failure.
|
|
Dispatcher.UIThread.RunJobs();
|
|
Assert.NotSame(expectedFocus, CurrentFocus(window));
|
|
}
|
|
finally
|
|
{
|
|
CloseTestWindow(window);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void OpeningAndClosingTheFirstRunWizardFocusesAndRunsTheCloseFallbackWithoutThrowing()
|
|
{
|
|
using LauncherWindowViewModel viewModel = CreateViewModel();
|
|
var window = new MainWindow { DataContext = viewModel };
|
|
try
|
|
{
|
|
window.Show();
|
|
|
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
|
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!;
|
|
Assert.Same(datDirectoryBox, CurrentFocus(window));
|
|
|
|
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
|
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
|
|
|
|
// See the comment in the editor-kind theory above: this pump is
|
|
// what actually executes the ProfilesTree.Focus() fallback.
|
|
Dispatcher.UIThread.RunJobs();
|
|
Assert.NotSame(datDirectoryBox, CurrentFocus(window));
|
|
}
|
|
finally
|
|
{
|
|
CloseTestWindow(window);
|
|
}
|
|
}
|
|
|
|
private static async Task OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing()
|
|
{
|
|
using LauncherWindowViewModel viewModel = CreateViewModel();
|
|
var window = new MainWindow { DataContext = viewModel };
|
|
try
|
|
{
|
|
window.Show();
|
|
|
|
await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync();
|
|
Assert.True(viewModel.UpdatePrompt.IsOpen);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
|
|
Assert.Same(closeButton, CurrentFocus(window));
|
|
|
|
viewModel.UpdatePrompt.CloseCommand.Execute(null);
|
|
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
|
|
|
// See the comment in the editor-kind theory above: this pump is
|
|
// what actually executes the ProfilesTree.Focus() fallback.
|
|
Dispatcher.UIThread.RunJobs();
|
|
Assert.NotSame(closeButton, CurrentFocus(window));
|
|
}
|
|
finally
|
|
{
|
|
CloseTestWindow(window);
|
|
}
|
|
}
|
|
|
|
private static void ClosingAModalRestoresThePreviouslyFocusedControlWithoutThrowing()
|
|
{
|
|
using LauncherWindowViewModel viewModel = CreateViewModel();
|
|
var window = new MainWindow { DataContext = viewModel };
|
|
try
|
|
{
|
|
window.Show();
|
|
|
|
// ProfilesTree itself is not a Fluent focus target (its template
|
|
// sets Focusable="False"; individual TreeViewItem rows are the
|
|
// real tab stops), so use another genuinely focusable, always
|
|
// visible control from the same non-modal chrome as the
|
|
// "previously focused" anchor for the _focusBeforeModal != null
|
|
// branch of MainWindow.OnViewModelPropertyChanged.
|
|
Control addServerButton = window
|
|
.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => Equals(button.Content, "+ Server"));
|
|
addServerButton.Focus();
|
|
Assert.Same(addServerButton, CurrentFocus(window));
|
|
|
|
viewModel.EditorDialog.Open(ProfileEditorKind.AddServer, "Fixture title", _ => { });
|
|
Dispatcher.UIThread.RunJobs();
|
|
Control serverName = (Control)GetNamedField(window, "ServerNameTextBox")!;
|
|
Assert.Same(serverName, CurrentFocus(window));
|
|
|
|
viewModel.EditorDialog.Close();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
// addServerButton was focused before the dialog opened, so the
|
|
// restore branch (focusToRestore.Focus()) is what ran here, not
|
|
// the ProfilesTree.Focus() fallback exercised by the tests above.
|
|
Assert.Same(addServerButton, CurrentFocus(window));
|
|
}
|
|
finally
|
|
{
|
|
CloseTestWindow(window);
|
|
}
|
|
}
|
|
|
|
private static void CloseTestWindow(MainWindow window)
|
|
{
|
|
// Avalonia objects are thread-affine. A shown test window must be
|
|
// closed and its compositor cleanup pumped by the same Avalonia test
|
|
// session that created it; leaving it for runner/GC teardown made the
|
|
// full parallel solution intermittently clean it from another thread.
|
|
if (window.IsVisible)
|
|
{
|
|
window.Close();
|
|
}
|
|
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static Control? CurrentFocus(MainWindow window) =>
|
|
Avalonia.Controls.TopLevel.GetTopLevel(window)?.FocusManager?.GetFocusedElement()
|
|
as Control;
|
|
|
|
private static object? GetNamedField(MainWindow window, string name) =>
|
|
typeof(MainWindow)
|
|
.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
|
?.GetValue(window);
|
|
|
|
private static LauncherWindowViewModel CreateViewModel()
|
|
{
|
|
var viewModel = new LauncherWindowViewModel(
|
|
new StubOrchestrator(),
|
|
new ImmediateUiDispatcher());
|
|
viewModel.Initialize();
|
|
return viewModel;
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate-round-1 review F1: the crash reporter's safety rests on the
|
|
/// invariant that no code path interpolates a credential VALUE into an
|
|
/// exception message — the launcher genuinely holds passwords
|
|
/// (ProfileEditorDialogViewModel, AccountProfile.Password,
|
|
/// StartRequest.Password), so "no password in any field" was never the
|
|
/// guarantee. This test pins the real one against the most
|
|
/// credential-adjacent realistic failure: a profiles-shaped document
|
|
/// that CONTAINS the password and is corrupted AFTER it, so the JSON
|
|
/// parser has consumed the credential value before throwing.
|
|
/// System.Text.Json quotes paths and positions, never values — if that
|
|
/// (or any future throw site) ever changes, this fails and the sink
|
|
/// needs the status-stream's credential scanning.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CrashReportNeverContainsAStoredPassword()
|
|
{
|
|
string root = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"acdream-tests",
|
|
Path.GetRandomFileName());
|
|
string dataDirectory = Path.Combine(root, "data");
|
|
const string password = "hunter2-gate-round-1-secret";
|
|
string corruptProfiles =
|
|
"{ \"version\": 1, \"servers\": [ { \"name\": \"s\", \"host\": \"h\", "
|
|
+ "\"port\": 9000, \"accounts\": [ { \"account\": \"a\", \"password\": \""
|
|
+ password
|
|
+ "\", \"characters\": [ } ] } ] }";
|
|
|
|
Exception failure;
|
|
try
|
|
{
|
|
_ = System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(
|
|
corruptProfiles);
|
|
throw new InvalidOperationException(
|
|
"The corrupt fixture unexpectedly parsed; the test premise is broken.");
|
|
}
|
|
catch (System.Text.Json.JsonException jsonFailure)
|
|
{
|
|
failure = new InvalidOperationException(
|
|
"Profile load failed during startup.",
|
|
jsonFailure);
|
|
}
|
|
|
|
try
|
|
{
|
|
string? report = Program.TryWriteCrashReport(
|
|
["--data-dir", dataDirectory],
|
|
failure);
|
|
|
|
Assert.NotNull(report);
|
|
// Isolation re-pinned: the report must land under the caller's
|
|
// --data-dir, never the machine's real data root.
|
|
Assert.StartsWith(dataDirectory, report, StringComparison.OrdinalIgnoreCase);
|
|
string content = File.ReadAllText(report);
|
|
Assert.Contains("JsonException", content);
|
|
Assert.Contains(" at ", content);
|
|
Assert.DoesNotContain(password, content, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(root))
|
|
{
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal no-op orchestrator. These tests exercise MainWindow's own
|
|
/// dispatcher/focus wiring, not orchestrator behavior (already covered
|
|
/// by <see cref="LauncherWindowViewModelTests"/>), so every mutation is
|
|
/// a no-op and the snapshot is deliberately empty.
|
|
/// </summary>
|
|
private sealed class StubOrchestrator : ILauncherOrchestrator
|
|
{
|
|
// Never raised: these tests exercise MainWindow's dispatcher/focus
|
|
// wiring directly and never trigger an orchestrator-side refresh.
|
|
#pragma warning disable CS0067
|
|
public event EventHandler? StateChanged;
|
|
#pragma warning restore CS0067
|
|
|
|
public void LoadProfiles()
|
|
{
|
|
}
|
|
|
|
public LauncherStateSnapshot GetSnapshot() => new(
|
|
Servers: [],
|
|
Sessions: [],
|
|
Platform: LauncherPlatformCapabilities.Detect(),
|
|
IsInstallationReady: false,
|
|
InstallationStatus: "Fixture: installation not ready.");
|
|
|
|
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
|
LauncherCapability.Available;
|
|
|
|
public LauncherCapability GetAccountLaunchCapability(
|
|
string serverName,
|
|
string accountName,
|
|
LaunchMode mode) => LauncherCapability.Available;
|
|
|
|
public LauncherCapability GetProbeCapability(string serverName, string accountName) =>
|
|
LauncherCapability.Available;
|
|
|
|
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
|
{
|
|
}
|
|
|
|
public void AddServer(string name, string host, int port)
|
|
{
|
|
}
|
|
|
|
public void EditServer(string name, string newName, string newHost, int newPort)
|
|
{
|
|
}
|
|
|
|
public void RemoveServer(string name)
|
|
{
|
|
}
|
|
|
|
public void AddAccount(string serverName, string accountName, string password)
|
|
{
|
|
}
|
|
|
|
public void EditAccount(
|
|
string serverName,
|
|
string accountName,
|
|
string newAccountName,
|
|
string? newPassword)
|
|
{
|
|
}
|
|
|
|
public void RemoveAccount(string serverName, string accountName)
|
|
{
|
|
}
|
|
|
|
public void AddCharacter(
|
|
string serverName,
|
|
string accountName,
|
|
string characterName,
|
|
string? characterId)
|
|
{
|
|
}
|
|
|
|
public void EditCharacterIdentity(
|
|
string serverName,
|
|
string accountName,
|
|
string characterName,
|
|
string newCharacterName,
|
|
string? newCharacterId)
|
|
{
|
|
}
|
|
|
|
public void UpdateCharacterSettings(
|
|
string serverName,
|
|
string accountName,
|
|
string characterName,
|
|
LaunchMode launchMode,
|
|
IReadOnlyList<string> plugins,
|
|
IReadOnlyList<string> loginCommands)
|
|
{
|
|
}
|
|
|
|
public void RemoveCharacter(string serverName, string accountName, string characterName)
|
|
{
|
|
}
|
|
|
|
public Task<LauncherSessionSnapshot> LaunchAsync(
|
|
string serverName,
|
|
string accountName,
|
|
string? characterName,
|
|
LaunchMode mode,
|
|
CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(CreateSession());
|
|
|
|
public Task<LauncherSessionSnapshot> ProbeAsync(
|
|
string serverName,
|
|
string accountName,
|
|
CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(CreateSession());
|
|
|
|
public Task StopSessionAsync(
|
|
string sessionId,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
|
|
public void PollStatus()
|
|
{
|
|
}
|
|
|
|
public void ClearFinishedSessions()
|
|
{
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private static LauncherSessionSnapshot CreateSession() => new(
|
|
"fixture-session",
|
|
LauncherActivityKind.Play,
|
|
"Fixture server",
|
|
"fixture-account",
|
|
"+Fixture",
|
|
LaunchMode.Gui,
|
|
LauncherActivityState.Connected,
|
|
"Connected.",
|
|
ExitCode: null,
|
|
Error: null,
|
|
CreatedAt: DateTimeOffset.UnixEpoch);
|
|
}
|
|
}
|