acdream/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs
Erik a01ff42640 feat(launcher): LU2/LU3 — one update question at startup, and it restarts itself
The update surface was a panel the user had to reason about: Check again,
Rollback client, Stage launcher, Install client, Cancel, Close, plus an
installed/available version table, a minimum-launcher-version sentence, and a
"restart required" banner they had to act on. Reaching it meant knowing to
press "Check for updates" in the header.

Now: the feed is checked once at startup. If nothing is out of date, nothing
appears. If something is, one dialog says what is new and offers Update or
Not now.

Launcher before client, deliberately. A client release can declare a minimum
launcher version, so updating the launcher first is what makes the client
update installable at all — and it means nobody is ever shown "install
launcher X or newer before the client update", which is not a sentence a
player should have to read.

A launcher update now restarts into the new build by itself. That reuses the
existing, proven handoff rather than inventing a second one: LauncherSelfUpdate
Bootstrap.TryApplyStagedUpdateNowAsync starts the staged payload in helper mode
against the CURRENT process, exactly as ordinary startup does, and the launcher
then shuts down. Restarting by spawning a fresh copy of the current launcher and
letting its startup notice the staged plan would look simpler and be wrong: the
helper would wait on the new copy while the old one still held its own
executable mapped, so the file replacement could fail. The staged-helper launch
is extracted into one private method both paths call, so they cannot drift.

Deleted: the header "Check for updates" button, OpenCommand, CheckCommand,
InstallClientCommand, StageLauncherCommand, RollbackCommand, CloseCommand, the
version table, IsLauncherMinimumBlocked/MinimumLauncherStatus, the restart
banner, and LauncherUpdatePhase plumbing through the view model.

NOT deleted — none of the safety changed: manifest validation, bounded verified
download, safe ZIP extraction, versioned install with an atomic current.json
switch, the update session barrier, and rollback all still live in
AcDream.Launcher.Core/Updates. Rollback simply has no button; it remains
reachable as Core API with its own tests. The complexity the user objected to
was the panel, not the machinery underneath it.

An unreachable feed stays silent. A friend with no internet must still reach
their characters, so a failed startup check shows nothing at all rather than an
error to dismiss.

Tests: LauncherUpdateViewModelTests rewritten against the new surface (8 tests
— nothing-to-do stays silent, client update installs, launcher update stages
then restarts without touching the client, no-restart-seam fallback, silent
offline, Not now, refused while a session runs, failed install reports why).
Tests for the deleted commands are removed with them, not skipped.
Launcher 59 passed, Launcher.Core 335 passed.

Campaign LU slices LU2 and LU3, landed together because the new prompt replaces
the old one in the same files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:49:19 +02:00

643 lines
26 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()
{
// LU3: the update question has no Open command any more — it appears
// by itself, once, when the startup check finds something out of date.
using LauncherWindowViewModel viewModel = CreateViewModel(
new UpdateAvailableUpdater());
var window = new MainWindow { DataContext = viewModel };
try
{
window.Show();
await viewModel.UpdatePrompt.StartupCheckAsync();
Assert.True(viewModel.UpdatePrompt.IsOpen);
Dispatcher.UIThread.RunJobs();
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
Assert.Same(closeButton, CurrentFocus(window));
viewModel.UpdatePrompt.NotNowCommand.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(
AcDream.Launcher.Core.Updates.ILauncherUpdater? updater = null)
{
var viewModel = new LauncherWindowViewModel(
new StubOrchestrator(),
new ImmediateUiDispatcher(),
installer: null,
updater: updater);
viewModel.Initialize();
return viewModel;
}
/// <summary>
/// LU3: the smallest updater that makes the startup check open the update
/// question, so the dialog's focus behavior stays covered now that it has
/// no Open command.
/// </summary>
private sealed class UpdateAvailableUpdater
: AcDream.Launcher.Core.Updates.ILauncherUpdater
{
private static readonly AcDream.Launcher.Core.Updates.LauncherVersion One =
AcDream.Launcher.Core.Updates.LauncherVersion.Parse("1.0.0");
private static readonly AcDream.Launcher.Core.Updates.LauncherVersion Two =
AcDream.Launcher.Core.Updates.LauncherVersion.Parse("2.0.0");
public AcDream.Launcher.Core.Updates.ClientVersionResolution CurrentClient =>
new(
AcDream.Launcher.Core.Updates.ClientVersionState.Verified,
"Fixture client verified.",
One,
Path.Combine("fixture", One.Value),
null,
null);
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
InitializeAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
public Task<AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default)
{
var artifact = new AcDream.Launcher.Core.Updates.ReleaseArtifact(
new Uri("https://example.test/release.zip"),
new string('a', 64),
100);
var manifest = new AcDream.Launcher.Core.Updates.ReleaseManifest(
Two,
One,
new Dictionary<string, AcDream.Launcher.Core.Updates.ReleaseArtifact>
{
["win-x64"] = artifact,
},
new Dictionary<string, AcDream.Launcher.Core.Updates.ReleaseArtifact>
{
["win-x64"] = artifact,
});
return Task.FromResult(
new AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult(
manifest,
"win-x64",
One,
One,
IsClientUpdateAvailable: true,
IsLauncherUpdateAvailable: false,
IsLauncherMinimumSatisfied: true,
"Fixture update available."));
}
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
InstallClientAsync(
AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult check,
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
public Task<AcDream.Launcher.Core.Updates.SelfUpdateStageResult>
StageLauncherAsync(
AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult check,
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromResult(new AcDream.Launcher.Core.Updates.SelfUpdateStageResult(
Two,
"pending.json",
"Launcher staged."));
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
RollbackClientAsync(
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
}
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);
}
}