diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 757827b9..3586c816 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -78,10 +78,10 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Install .NET 10 + - name: Install pinned .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: "10.0.x" + global-json-file: global.json # No apt step here on purpose. This job's whole claim is that the closure # below is presentation-free: it builds Bake, Plugin.Abstractions, Core, @@ -159,10 +159,10 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Install .NET 10 + - name: Install pinned .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: "10.0.x" + global-json-file: global.json - name: Build and test the portable launcher shell: pwsh @@ -231,10 +231,10 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Install .NET 10 + - name: Install pinned .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: "10.0.x" + global-json-file: global.json - name: Build and publish Linux graphical client shell: pwsh @@ -324,10 +324,10 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Install .NET 10 + - name: Install pinned .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: "10.0.x" + global-json-file: global.json - name: Install lavapipe, the Vulkan loader and Xvfb shell: bash diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 00000000..949e26d2 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,38 @@ +name: Complete Release gate + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + complete-release: + name: Complete Release suite (Windows) + runs-on: windows-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Run complete bounded Release gate + shell: pwsh + run: ./tools/run-release-gate.ps1 + + - name: Upload Release gate evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-gate-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + retention-days: 14 + path: artifacts/release-gate/ diff --git a/docs/plans/2026-08-18-release-stabilization.md b/docs/plans/2026-08-18-release-stabilization.md index 76040c37..9e1ed052 100644 --- a/docs/plans/2026-08-18-release-stabilization.md +++ b/docs/plans/2026-08-18-release-stabilization.md @@ -419,9 +419,9 @@ checkpoint. Do not infer status from chat history. | Slice | Status | Commit(s) | Evidence/gates | Exact next action | |---|---|---|---|---| | R0 | PLAN ACCEPTED; tracking/owner assignment pending | — | Audit complete at `15539a22`; user started R1 | Track plan+audit artifacts; assign R4 owner | -| R1 | IMPLEMENTED + REQUIRED GATES PASS; review/commit pending | — | 2026-08-18 checkpoint below; F-009/T-001 resolved in working tree | Review diff, then commit the coherent R1 checkpoint | -| R2 | NOT STARTED; technically unblocked after R1 review/commit | — | F-010/F-014/F-019 | Start only after R1 checkpoint is accepted | -| R3 | NOT STARTED | — | T-002–T-016; F-015/F-021/F-022/F-030 | Wait for R2 | +| R1 | COMPLETE ON CAMPAIGN BRANCH; not yet merged | `0a934cf5` | 2026-08-18 checkpoint below; F-009/T-001 resolved and committed | Preserve while R2 runs; merge through the normal review path | +| R2 | COMPLETE GATE CHECKPOINT; broader reproducibility work remains; commit pending | — | Checkpoint below; F-014 resolved, F-010/F-019 partially resolved | Review and commit this checkpoint, then choose locked restore/warning work or R3 | +| R3 | NOT STARTED | — | T-002–T-018; F-015/F-021/F-022/F-030 | Wait for remaining R2 sequencing decision | | R4 | NOT STARTED; may run parallel | — | F-001/F-026/F-031/F-034 | Assign owner/legal provenance decision path | | R5 | NOT STARTED | — | F-002/F-003/F-006/F-007/F-011/F-020/F-027/F-029/F-032 | Wait for R2–R3 | | R6 | NOT STARTED | — | F-004/F-005/F-012/F-016/F-023/F-028 | Wait for R3/R5 | @@ -433,8 +433,8 @@ checkpoint. Do not infer status from chat history. ### R1 implementation checkpoint — 2026-08-18 **Working-tree base:** `15539a22a67f8d915d88f8b1d8126cd55eedda6e` -**Commit:** pending; do not describe this checkpoint as merged or durable until -the source, test, plan, and audit records are committed together. +**Commit:** `0a934cf5` on `codex/release-stabilization`; the checkpoint is +durable on that campaign branch but is not yet merged to `main`. Implementation: @@ -495,9 +495,79 @@ Changed implementation/test files: - `src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs` - `tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs` -Rollback before commit is the exact reverse of those two file diffs. After a -future commit, record its hash here and use `git revert ` rather than -rewriting history. +Rollback is `git revert 0a934cf5`; do not rewrite branch history. + +### R2 complete-gate checkpoint — 2026-08-18 + +**Working-tree base:** `0a934cf5781c003375c14af9a1f565254df0f9f9` +**Commit:** pending; the source and evidence records must be committed together +before this checkpoint is described as durable. + +Implemented gate: + +- `global.json` pins the accepted .NET 10 SDK feature band at `10.0.300` with + `latestPatch` roll-forward and prerelease SDKs disabled. Every existing + `actions/setup-dotnet` step now reads that file instead of floating on + `10.0.x`. +- `tools/run-release-gate.ps1` discovers every project under `tests/` that + declares `Microsoft.NET.Test.Sdk` or `IsTestProject`, verifies the project is + present in `AcDream.slnx`, restores/builds the solution, and runs each test + assembly exactly once in its own Release process. It does not retry. +- Restore, build, and each test process have 600/900/600-second outer bounds. + Tests additionally use VSTest's 180-second per-test blame-hang collector with + mini dumps. An outer timeout kills the complete process tree and reports exit + code 124; GitHub Actions adds a 45-minute job bound. +- Every run writes exact commands and output, one TRX per assembly, any blame + sequence/dumps, `dotnet --info`, configured NuGet sources, commit/branch/RID, + aggregate executed/passed/skipped/failed counts, and `SHA256SUMS.txt`. +- `.github/workflows/release-gate.yml` runs the gate on pull requests, pushes + to `main`, and manual dispatch on `windows-latest`, then uploads the evidence + even when the gate fails. The focused Windows/Linux portability and Vulkan + lanes remain separate and are no longer the only deterministic CI coverage. +- `docs/release-gate.md` is the repository-owned local/CI runbook. + +Test-isolation corrections, with no product behavior change: + +- Four `MainWindowViewTests` that call `Show()` now close their window and pump + dispatcher cleanup in `finally` on the owning Avalonia test session. The old + tests leaked shown, thread-affine compositor state to runner teardown; no + suite serialization or retry was added. +- The complete gate's first evidence run correctly failed + `RealChildStderrIsCapturedForTheProcessStartInfoPath`: its live polling helper + briefly denied write sharing, so the final async stderr callback observed an + `IOException` and the deliberately no-throw capture sink latched off. The + helper now reads with `FileShare.ReadWrite | FileShare.Delete`, matching the + production status tailer; its assertions and five-second bound are unchanged. + +Verification: + +- Focused `MainWindowViewTests`: 13 passed / 0 skipped / 0 failed. +- Three fresh default-parallel whole-solution runs completed inside independent + 180-second process bounds with 12/12 TRX files and no Avalonia cleanup error: + 56.719, 56.321, and 58.506 seconds. Each reported 14,748 passed / 77 skipped / + 0 failed. +- The stderr ProcessStartInfo test passed 25/25 fresh-process repetitions after + the live-reader correction. +- The actual outer-watchdog function killed a controlled fixture process tree + at 2.107 seconds, returned 124, and left no child process. +- The repository command `pwsh ./tools/run-release-gate.ps1` completed in + 107.337 seconds on SDK `10.0.300`, RID `win-x64`: 12 assemblies, 14,748 + executed and passed / 77 skipped / 0 failed. The evidence manifest contains + 28 verified SHA-256 entries. + +Deliberately still open in the wider R2 slice: + +- the 26 warnings observed by a clean recompilation, centralized compiler and + package settings, package lock files/locked restore, and broken or unsupported + tool-project decisions; +- the known duplicate Core theory row and classification of the 77 skips, which + remain R3 work; and +- stale public headline counts, which must be corrected with the documentation + authority work rather than hand-edited as part of this gate checkpoint. + +Therefore this checkpoint closes F-014 on the campaign branch and the SDK part +of F-019, and gives F-010 a truthful machine-readable count. It does not claim +the broader R2 reproducibility slice or R3 test-quality cleanup is complete. ## 18. Session start protocol @@ -532,7 +602,7 @@ review, and cross-session ledger entry are all complete. ## 20. Immediate next action -Review the R1 diff and checkpoint evidence, then commit the source, regression -test, plan, and audit updates together if accepted. After that, begin R2's -pinned complete gate; assign the R4 decision owner in parallel. Do not begin -bulk comment, artifact, or giant-file cleanup first. +Review and commit the R2 complete-gate checkpoint, then decide whether the next +bounded goal is the remaining R2 locked-restore/warning/toolchain work or R3's +truthful test taxonomy. Assign the R4 decision owner in parallel. Do not begin +bulk comment, artifact, giant-file, or unrelated cleanup first. diff --git a/docs/release-gate.md b/docs/release-gate.md new file mode 100644 index 00000000..9f3dfdb0 --- /dev/null +++ b/docs/release-gate.md @@ -0,0 +1,41 @@ +# Complete Release gate + +The default release gate is repository-owned and uses the SDK feature band in +`global.json`: + +```powershell +pwsh ./tools/run-release-gate.ps1 +``` + +The command restores and builds `AcDream.slnx`, discovers every project under +`tests/` that declares `Microsoft.NET.Test.Sdk` or `IsTestProject`, verifies +that the solution includes it, and runs each test assembly once in a fresh +Release process. It does not retry failures. + +Each restore, build, and test process has an outer hard timeout. Every test +also runs with VSTest blame-hang enabled: after three minutes in one test, the +test host is terminated and a mini dump is collected; after ten minutes, the +outer watchdog kills the complete `dotnet test` process tree. CI additionally +has a 45-minute job bound. + +Evidence is written to `artifacts/release-gate/`: + +- `release-gate-summary.json` records the commit, branch, worktree state, SDK, + RID, bounds, process outcomes, assembly list, and + executed/passed/skipped/failed totals; +- `environment.txt` records `dotnet --info`, configured NuGet sources, and the + discovered test-project set; +- `test-results/` contains one TRX per assembly plus any VSTest hang sequence + and dump files; +- `logs/` contains the exact command and complete output for every child + process; and +- `SHA256SUMS.txt` hashes the evidence bundle. + +The complete gate runs on Windows because it exercises the full product and +launcher surface. `.github/workflows/headless-portability.yml` remains the +focused Windows/Linux portability and Vulkan evidence workflow; those jobs are +not substitutes for the complete gate. + +Environment-dependent tests retain their current skip behavior and are counted +explicitly in the JSON/TRX report. Classifying or replacing those tests belongs +to R3, not to this gate checkpoint. diff --git a/docs/reviews/2026-08-17-release-maintainability-audit.md b/docs/reviews/2026-08-17-release-maintainability-audit.md index 7d9f8c95..9a8a0cd4 100644 --- a/docs/reviews/2026-08-17-release-maintainability-audit.md +++ b/docs/reviews/2026-08-17-release-maintainability-audit.md @@ -178,14 +178,26 @@ external systems. ## Post-audit stabilization update — 2026-08-18 -R1 has been implemented and fully gated in the current uncommitted working -tree. The launcher child is detached under the supervisor gate and stopped, +R1 has been implemented, fully gated, and preserved at `0a934cf5` on +`codex/release-stabilization`. The launcher child is detached under the supervisor gate and stopped, unsubscribed, and disposed outside it; a deterministic captured-callback race test fails against the old mechanism and passes against the fix. Launcher.Core passes 339/339, and two fresh serialized complete Release runs each finish inside a 900-second hard bound with 14,748 passes / 77 skips / 0 failures. This resolves F-009 technically but does not change the baseline audit's -historical count until the coherent R1 checkpoint is reviewed and committed. -The public-release decision remains no-go because F-001, F-002, F-014, and the -remaining high-priority release-governance findings are untouched. +historical count; the checkpoint is committed but not yet merged to `main`. + +The subsequent R2 gate checkpoint pins SDK feature band `10.0.300`, adds a +repository-owned bounded Release command and Windows CI workflow that discover +and run all 12 test assemblies, captures TRX/log/environment/hash evidence, and +collects mini dumps before an outer process-tree timeout. Its local evidence +run reports 14,748 executed and passed / 77 skipped / 0 failed in 107.337 +seconds. Three additional default-parallel solution runs pass without the +previous Avalonia compositor cleanup failure after shown test windows were +closed on their owning session. + +This resolves F-014 technically on the campaign branch and partially addresses +F-010/F-019. The public-release decision remains no-go because F-001, F-002, +the 26 clean-build warnings, unpinned package graph, and the remaining +high-priority release-governance/test-truth findings are untouched. diff --git a/docs/reviews/findings-ledger.md b/docs/reviews/findings-ledger.md index b39d4d5a..820f01b1 100644 --- a/docs/reviews/findings-ledger.md +++ b/docs/reviews/findings-ledger.md @@ -214,8 +214,8 @@ did not find an actionable exception beyond the documented classes. test that proves both exit-callback and disposal orders converge. Audit the ordinary `SystemChildProcess` wrapper for the same inversion. -**Post-baseline resolution checkpoint (2026-08-18, uncommitted):** the working -tree now performs the ownership transfer and all child operations outside +**Post-baseline resolution checkpoint (2026-08-18, `0a934cf5`):** the campaign +branch now performs the ownership transfer and all child operations outside `_gate`; `OnProcessExited` obtains its optional exit code without holding that gate. A barrier-controlled regression captures the exit delegate before unsubscription and makes child disposal wait for that callback. It times out in @@ -223,9 +223,8 @@ five seconds with the old lock shape, passes in milliseconds with the fix, and passed 25/25 fresh-process repetitions. Launcher.Core passes 339/339. Two fresh serialized complete-solution runs each finished under a 900-second hard bound with 14,748 passes / 77 skips / 0 failures in 1:28.822 and 1:30.241. -F-009 remains a baseline finding until the coherent R1 checkpoint is reviewed -and committed; the current working-tree mechanism and required gates are -resolved. +F-009 remains in this historical baseline, but its mechanism and required gates +are resolved in a durable campaign-branch commit that is not yet merged. ## F-010 — Published build/test baseline materially overstates the portable gate @@ -250,6 +249,13 @@ resolved. Fail on unexpected skips and duplicate discovery IDs; do not hand-edit test totals into multiple living documents. +**Post-baseline gate checkpoint (2026-08-18, commit pending):** the new +repository gate emits per-assembly TRX plus a JSON summary that distinguishes +14,748 executed/passed cases from 77 reported skips and retains the known +duplicate-discovery warning in the process log. This makes the complete +portable count reproducible, but F-010 remains open until R3 classifies false +passes/skips/diagnostics and the stale public headline claims are replaced. + ## F-011 — Mandatory WorldBuilder inventory is an obsolete OpenGL design guide - **Severity:** P1 high @@ -341,6 +347,18 @@ resolved. split environment-dependent gates explicitly. Give every job a hang timeout and collect dumps; make the portable complete gate required before release. +**Post-baseline resolution checkpoint (2026-08-18, commit pending):** +`.github/workflows/release-gate.yml` now runs the repository-owned Release gate +on Windows for pull requests, `main` pushes, and manual dispatch. The script +auto-discovers and verifies all 12 solution test projects, runs each once in a +fresh bounded process, arms VSTest mini-dump collection before the outer +process-tree watchdog, and uploads commands, logs, TRX, environment metadata, +aggregate counts, and hashes even on failure. The exact local workflow command +passed 14,748 executed / 77 skipped / 0 failed in 107.337 seconds; an isolated +watchdog probe returned 124 at 2.107 seconds and left no child. F-014 is +technically resolved on the campaign branch, pending commit/review and actual +GitHub execution after publication. + ## F-015 — Headline pass totals include hundreds of unexecuted test contracts - **Severity:** P1 high @@ -471,6 +489,13 @@ resolved. that proves a clean checkout. Keep tool-only exceptions explicit rather than allowing per-project drift. +**Post-baseline partial checkpoint (2026-08-18, commit pending):** `global.json` +pins SDK feature band `10.0.300` with `latestPatch` roll-forward, and all five +CI setup sites now consume that file. `docs/release-gate.md` documents the one +complete command and its evidence. F-019 remains open: common compiler/package +settings are still duplicated, there are no package lock files or locked +restore, and the unsupported tool-project decisions have not been made. + ## F-020 — The issue ledger is an unbounded mixed tracker, research log, and archive - **Severity:** P1 high diff --git a/docs/reviews/test-quality-audit.md b/docs/reviews/test-quality-audit.md index c51bd179..ea6bd059 100644 --- a/docs/reviews/test-quality-audit.md +++ b/docs/reviews/test-quality-audit.md @@ -120,7 +120,7 @@ callback. The test needs a deterministic race harness after the production fix; the current timing-dependent version alternates between passing in 48 s and hanging indefinitely. -**Post-baseline resolution checkpoint (2026-08-18, uncommitted):** +**Post-baseline resolution checkpoint (2026-08-18, `0a934cf5`):** `DisposeAllowsAnAlreadyCapturedExitCallbackToComplete` is now the deterministic race harness. Explicit barriers make the fake child capture the exit delegate, release it from inside disposal, and wait for the callback to return. The old @@ -401,3 +401,28 @@ regression contracts with T-011's output apparatus. When a test graduates into a stable contract, organize it under the owning component/behavior and retain the issue ID in a trait or comment; keep investigation programs in a separate, non-default diagnostic project. + +### T-017 — Shown Avalonia test windows escaped their owning UI session + +Four `MainWindowViewTests` called `window.Show()` but never closed the window. +One default-parallel solution run then failed during Avalonia headless cleanup: +the runner/compositor touched an object from a thread other than the one that +owned it, even though the Launcher project passed alone. The tests now close +shown windows and pump dispatcher cleanup in `finally` on the owning Avalonia +session. No product code, suite serialization, or retry was added. The focused +class passes 13/13, and three fresh default-parallel complete-solution runs pass +all 12 assemblies in 56.719, 56.321, and 58.506 seconds. + +### T-018 — A live stderr assertion could disable the producer it observed + +`ReadFileEventuallyContainingAsync` used `File.ReadAllTextAsync` while the +ProcessStartInfo stderr callbacks were still draining. Its read handle did not +share writes. If the final callback overlapped that short read, the capture +sink saw a sharing violation, deliberately latched into its no-throw state, and +the test waited five seconds for a line it had itself caused to be dropped. The +first complete repository-gate run exposed exactly that two-of-three-lines +failure. The helper now opens a live reader with +`FileShare.ReadWrite | FileShare.Delete`, matching the production status +tailer; the expected lines and timeout are unchanged. The test subsequently +passed 25/25 fresh-process runs and Launcher.Core passed 339/339 in the final +gate. diff --git a/global.json b/global.json new file mode 100644 index 00000000..f7772fee --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.300", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index e596a927..ae66fb73 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -750,7 +750,18 @@ public sealed class LauncherProcessSupervisorTests { try { - last = await File.ReadAllTextAsync(path); + // This is a live-file poll, not a post-close read. Match + // the production status tailer's sharing contract so the + // assertion cannot transiently deny the stderr callback's + // final append and make the no-throw capture sink latch + // itself off before the expected line arrives. + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + last = await reader.ReadToEndAsync(); if (last.Contains(expectedFragment, StringComparison.Ordinal)) { return last; diff --git a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs index 39713aac..62a1cf6c 100644 --- a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs +++ b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs @@ -128,32 +128,39 @@ public sealed class MainWindowViewTests { using LauncherWindowViewModel viewModel = CreateViewModel(); var window = new MainWindow { DataContext = viewModel }; - window.Show(); + try + { + window.Show(); - viewModel.EditorDialog.Open(kind, "Fixture title", _ => { }); - Assert.True(viewModel.EditorDialog.IsOpen); - Dispatcher.UIThread.RunJobs(); + 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)); + Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!; + Assert.Same(expectedFocus, CurrentFocus(window)); - viewModel.EditorDialog.Close(); - Assert.False(viewModel.EditorDialog.IsOpen); + 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)); + // 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); + } } [AvaloniaFact] @@ -161,22 +168,29 @@ public sealed class MainWindowViewTests { using LauncherWindowViewModel viewModel = CreateViewModel(); var window = new MainWindow { DataContext = viewModel }; - window.Show(); + try + { + window.Show(); - viewModel.FirstRunWizardShell.OpenCommand.Execute(null); - Assert.True(viewModel.FirstRunWizardShell.IsOpen); - Dispatcher.UIThread.RunJobs(); + viewModel.FirstRunWizardShell.OpenCommand.Execute(null); + Assert.True(viewModel.FirstRunWizardShell.IsOpen); + Dispatcher.UIThread.RunJobs(); - Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!; - Assert.Same(datDirectoryBox, CurrentFocus(window)); + Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!; + Assert.Same(datDirectoryBox, CurrentFocus(window)); - viewModel.FirstRunWizardShell.CloseCommand.Execute(null); - Assert.False(viewModel.FirstRunWizardShell.IsOpen); + 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)); + // 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); + } } [AvaloniaFact] @@ -184,22 +198,29 @@ public sealed class MainWindowViewTests { using LauncherWindowViewModel viewModel = CreateViewModel(); var window = new MainWindow { DataContext = viewModel }; - window.Show(); + try + { + window.Show(); - await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync(); - Assert.True(viewModel.UpdatePrompt.IsOpen); - Dispatcher.UIThread.RunJobs(); + await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync(); + Assert.True(viewModel.UpdatePrompt.IsOpen); + Dispatcher.UIThread.RunJobs(); - Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!; - Assert.Same(closeButton, CurrentFocus(window)); + Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!; + Assert.Same(closeButton, CurrentFocus(window)); - viewModel.UpdatePrompt.CloseCommand.Execute(null); - Assert.False(viewModel.UpdatePrompt.IsOpen); + 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)); + // 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); + } } [AvaloniaFact] @@ -207,33 +228,54 @@ public sealed class MainWindowViewTests { using LauncherWindowViewModel viewModel = CreateViewModel(); var window = new MainWindow { DataContext = viewModel }; - window.Show(); + 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