acdream/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs
Erik 981e168fb9 fix(launcher): Campaign LA gate-round-1 review findings F1-F6 + hardening
F1: the crash reporter comment claimed the launcher never holds a password
in any field - false (ProfileEditorDialogViewModel, AccountProfile.Password,
StartRequest.Password). Reworded to the true, narrower invariant (no throw
site interpolates a credential VALUE into an exception message) and pinned
it with CrashReportNeverContainsAStoredPassword: a real STJ failure over a
profiles document containing a known password, corrupted after the
credential, must yield a crash file with the stack and without the value.

F2: the co-deploy Inputs covered only Bake own sources; a Content edit
never refreshed the 83 MB exe. Now the full reference closure. Fixing it
surfaced two more incrementality traps, both fixed and comment-documented:
SkipUnchangedFiles left the output older than the triggering input (target
re-ran forever - added an explicit Touch), and %(Item.Metadata) in a plain
Include does not batch (the literal percent-text became a permanently
out-of-date phantom input - globs are now spelled per project). Verified:
Core edit retriggers, then two consecutive clean incremental builds.

F3: RID publishes ran BOTH co-deploy paths (two self-contained bake
publishes). Build-time target now guarded on _IsPublishing; verified a
real win-x64 publish runs zero build-target co-deploys and still ships
both exes.

F4: comment misattributed PublishBakeTool=false to CI lanes; it is
target-local recursion guarding. F5: the x:Name reflection sweep now walks
the markup as XML and tolerates template-scoped names (no generated field
exists for those). F6: dead using removed. Hardening: the crash reporter
positional --data-dir fallback requires a fully-qualified path so a
relative or flag-shaped value cannot create ./crash-reports at an
arbitrary CWD.

Launcher 67/67, Launcher.Core 317/317.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 08:19:23 +02:00

487 lines
19 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.
///
/// Every test here constructs a real <see cref="MainWindow"/> against the
/// real compiled XAML and drives it 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
/// test 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)),
];
[AvaloniaFact]
public 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);
}
}
[AvaloniaFact]
public 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.");
}
}
[AvaloniaTheory]
[InlineData(ProfileEditorKind.AddServer, "ServerNameTextBox")]
[InlineData(ProfileEditorKind.EditServer, "ServerNameTextBox")]
[InlineData(ProfileEditorKind.AddAccount, "AccountNameTextBox")]
[InlineData(ProfileEditorKind.EditAccount, "AccountNameTextBox")]
[InlineData(ProfileEditorKind.AddCharacter, "CharacterNameTextBox")]
[InlineData(ProfileEditorKind.EditCharacter, "CharacterNameTextBox")]
[InlineData(ProfileEditorKind.Remove, "EditorSubmitButton")]
public void OpeningEachEditorKindFocusesItsPrimaryFieldAndClosingRunsTheFallbackWithoutThrowing(
ProfileEditorKind kind,
string expectedFocusFieldName)
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
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));
}
[AvaloniaFact]
public void OpeningAndClosingTheFirstRunWizardFocusesAndRunsTheCloseFallbackWithoutThrowing()
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
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));
}
[AvaloniaFact]
public async Task OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing()
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
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));
}
[AvaloniaFact]
public void ClosingAModalRestoresThePreviouslyFocusedControlWithoutThrowing()
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
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));
}
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);
}
}