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>
487 lines
19 KiB
C#
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);
|
|
}
|
|
}
|