From 1f150bb89e23c7a552ea5d5597fb076bed981bb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:28:15 +0000 Subject: [PATCH 1/2] Grow the flight log with the window, and let its text out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flight log was pinned to a 104–150px box, so making the Fido window taller only opened a gap above it — and the narration it holds could only be read, never taken anywhere. The log panel now takes whatever vertical slack the window has beyond what the upper section needs: the window's height minus the upper stack's desired height, the log's own label row, and the countdown bar. Every input is a desired size or the window's own height, so the panel lands in one further layout pass instead of feeding back on itself. While the window is still auto-sizing to its content there is no slack by definition, and taking any would start a fight the two can't finish (the window trails the panel by a pass), so the panel stays content-sized until the user resizes — which is when Avalonia drops SizeToContent anyway. Too short a window behaves exactly as before: the panel holds its content-sized box and the upper section scrolls. Two icon buttons on the Flight log rule lift the narration out: copy puts the whole log on the clipboard as plain text, and save writes it to a text file picked through the storage provider (suggesting a dated name). Both are disabled until there's a line to hand over, cancelling the picker is silent, and everything else — a missing clipboard, a failed write — is reported in the log itself. The copy-path button's style is now shared as `iconaction`, with a dimmed :disabled state for the empty-log case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019pNcGRSKiKZiZZWp7zDP7V --- CHANGELOG.md | 9 + Docs/Features.md | 15 ++ src/Services/AvaloniaDialogService.cs | 26 +++ src/Services/IDialogService.cs | 6 + src/Theme/FidoStyles.axaml | 21 +- src/ViewModels/MainWindowViewModel.cs | 13 ++ src/Views/MainWindow.axaml | 42 +++- src/Views/MainWindow.axaml.cs | 131 +++++++++++- tests/Fido.Tests/E2E/FlightLogTests.cs | 191 ++++++++++++++++++ .../Infrastructure/FakeDialogService.cs | 16 ++ 10 files changed, 450 insertions(+), 20 deletions(-) create mode 100644 tests/Fido.Tests/E2E/FlightLogTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 068144b..83d581d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The flight log grows with the window, and its text can be copied or saved.** Drag Fido taller and + every spare pixel now goes to the **flight log** instead of to a gap above it — the upper section + keeps the room its content needs and the log takes the rest; shrink the window and the log falls back + to its compact box while the upper section scrolls as before. Two new buttons on the **Flight log** + rule lift the narration out: **copy** puts the whole log (every line, not just the visible ones) on + the clipboard as plain text, and **save** writes it to a text file you pick, suggesting a dated name + like `fido-flight-log-20260806-142317.txt`. Both are disabled until there's something to hand over, + and both confirm themselves in the log. + - **Copy the selected working-tree path to the clipboard.** The OPEN strip now has a small **copy button** beside the path, and the ellipsised card and strip paths carry a **tooltip with the full path** — so a long worktree path (previously truncated and un-selectable) can be read in full and diff --git a/Docs/Features.md b/Docs/Features.md index 8e16e44..3b1cf02 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -275,6 +275,21 @@ green `✓` for successes, plain `▸` for actions — and failures call it stra lines for a branch that isn't checked out anywhere, a tool that can't be located, or a delete that went wrong. +The panel **grows with the window**: drag Fido's bottom edge down and every spare pixel +goes to the log rather than to a gap above it — the upper section keeps as much room as +its content needs, and the log takes the rest. Shrink the window again and the log falls +back to its compact box while the upper section scrolls. + +Two buttons on the **Flight log** rule take the narration with you: + +- **Copy** puts the whole log — every line, not just the visible ones — on the clipboard + as plain text. +- **Save** writes it to a text file you pick, suggesting a dated name like + `fido-flight-log-20260806-142317.txt`. + +Both are disabled until there's something to hand over, and each confirms itself in the +log (`📋 Copied 8 flight-log line(s) to the clipboard.`, `✓ Flight log saved to …`). + ### Keyboard & shortcuts - The **branch** field is focused on launch. Typing debounces into a scan; **Enter** diff --git a/src/Services/AvaloniaDialogService.cs b/src/Services/AvaloniaDialogService.cs index a2137e9..5334edc 100644 --- a/src/Services/AvaloniaDialogService.cs +++ b/src/Services/AvaloniaDialogService.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Platform.Storage; using Fido.Models; using Fido.Views; @@ -16,4 +17,29 @@ public Task ConfirmForceDeleteWorktreeFolderAsync(WorktreeForceDelete requ public Task ShowSettingsAsync(AppConfig config, ConfigService configService) => new SettingsDialog(config, configService).ShowDialog(_owner); + + public async Task PickFlightLogPathAsync(string suggestedFileName) + { + var storage = _owner.StorageProvider; + if (!storage.CanSave) return null; + + var file = await storage.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save flight log", + SuggestedFileName = suggestedFileName, + DefaultExtension = "txt", + ShowOverwritePrompt = true, + FileTypeChoices = + [ + new FilePickerFileType("Text file") + { + Patterns = ["*.txt"], + MimeTypes = ["text/plain"], + }, + ], + }); + + // A picked file is written by path — Fido isn't sandboxed, and the log is plain text. + return file?.TryGetLocalPath(); + } } diff --git a/src/Services/IDialogService.cs b/src/Services/IDialogService.cs index df82cf3..ad74c87 100644 --- a/src/Services/IDialogService.cs +++ b/src/Services/IDialogService.cs @@ -19,4 +19,10 @@ public interface IDialogService /// Opens the settings dialog (modal). Task ShowSettingsAsync(AppConfig config, ConfigService configService); + + /// + /// Asks where to save the flight log, offering . Returns the + /// chosen path, or null when the user cancelled (or the platform has no save picker). + /// + Task PickFlightLogPathAsync(string suggestedFileName); } diff --git a/src/Theme/FidoStyles.axaml b/src/Theme/FidoStyles.axaml index d50b0a7..228808b 100644 --- a/src/Theme/FidoStyles.axaml +++ b/src/Theme/FidoStyles.axaml @@ -566,8 +566,8 @@ - - - - - - - + + + diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs index 0a56f0b..46d6e48 100644 --- a/src/ViewModels/MainWindowViewModel.cs +++ b/src/ViewModels/MainWindowViewModel.cs @@ -16,6 +16,10 @@ namespace Fido.ViewModels; /// public sealed class MainWindowViewModel : ObservableObject { + public MainWindowViewModel() => + // The log's copy/save actions are gated on there being something to hand over. + Log.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLog)); + // --- Inputs ----------------------------------------------------------------------- private string _branchName = ""; @@ -531,6 +535,15 @@ static void Replace(ObservableCollection target, IEnumerable sou /// Color-coded flight-log lines. public ObservableCollection Log { get; } = new(); + /// True once the log has a line in it — enables the copy/save actions above the panel. + public bool HasLog => Log.Count > 0; + + /// + /// The whole flight log as plain text, one line per entry — what the copy-to-clipboard and + /// save-to-file actions hand over. Colour levels are presentation only and don't survive the trip. + /// + public string LogText => string.Join(Environment.NewLine, Log.Select(line => line.Text)); + public void AppendLog(string message) { if (Dispatcher.UIThread.CheckAccess()) diff --git a/src/Views/MainWindow.axaml b/src/Views/MainWindow.axaml index eed60f3..e424204 100644 --- a/src/Views/MainWindow.axaml +++ b/src/Views/MainWindow.axaml @@ -14,12 +14,14 @@ Background="{DynamicResource FidoWindowBg}"> - + The upper stack scrolls if the discovery list outgrows the window; the flight log sits near the + bottom and takes whatever vertical slack the window has beyond the upper stack (see + MainWindow.UpdateFlightLogHeight); the auto-close countdown pins beneath it. --> + - - + + @@ -292,7 +294,7 @@ - + + - + @@ -464,7 +484,7 @@ - diff --git a/src/Views/MainWindow.axaml.cs b/src/Views/MainWindow.axaml.cs index cd95747..8d5516b 100644 --- a/src/Views/MainWindow.axaml.cs +++ b/src/Views/MainWindow.axaml.cs @@ -21,6 +21,13 @@ public partial class MainWindow : Window /// How long the branch box stays quiet before a scan fires (Enter fires immediately). internal static readonly TimeSpan ScanDebounce = TimeSpan.FromMilliseconds(600); + /// + /// The flight log's content-sized ceiling from the redesign: with the window only as tall as its + /// content the panel grows with the lines up to this, and no further. Past it the panel is sized by + /// the window's slack instead — see . + /// + private const double LogContentMaxHeight = 150; + private readonly MainWindowViewModel _vm = new(); private readonly ConfigService _configService; private readonly GitService _git; @@ -104,6 +111,11 @@ internal MainWindow(FidoServices services) _opener = new OpenerService(_git, services.Finder, services.WorkingTreeFinder, _vm.AppendLog, _vm.AppendLiveLog, gitHub: services.GitHub); _vm.Log.CollectionChanged += (_, _) => Dispatcher.UIThread.Post(ScrollLogToEnd, DispatcherPriority.Background); + // The flight log absorbs whatever vertical room the rest of the screen doesn't need, so a taller + // window means a taller log rather than a gap above it. Re-run after every layout pass: the + // window can be resized, and the upper stack's own height moves with the discovery results. + LayoutUpdated += (_, _) => UpdateFlightLogHeight(); + var startup = ApplyStartupArgs(); Opened += (_, _) => { @@ -524,6 +536,123 @@ internal async Task CopySelectedPathAsync() } } + // --- Flight log ----------------------------------------------------------------------- + + private void ScrollLogToEnd() => LogScroller.Offset = new Vector(0, LogScroller.Extent.Height); + + /// + /// Hands the flight log every pixel the rest of the screen isn't using, so making the window taller + /// grows the log panel rather than opening a gap above it. With no slack to give — a short window, or + /// a discovery list filling it — the panel falls back to the redesign's content-sized 104…150 box and + /// the upper section scrolls as before. + /// + private void UpdateFlightLogHeight() + { + // While the window is still auto-sizing to its content there is no slack by definition — the + // height is whatever the screen asked for — and taking any would start a fight the two can't + // finish: the window trails the panel by a layout pass, so it would shrink back to the panel's + // old height, hand out that difference again, and never settle. Avalonia drops SizeToContent the + // moment the user drags an edge, which is exactly when there is room to give. + if (SizeToContent is SizeToContent.Height or SizeToContent.WidthAndHeight) + { + ResetFlightLogHeight(); + return; + } + + // The upper section scrolls, so it never needs more than its content's height: what's left of the + // window after that, the log's own label row, and the countdown bar belongs to the panel. Both + // subtrahends are *desired* heights — unchanged by what the panel is actually given — so with the + // window's height fixed this lands in one further layout pass. + var chrome = LogRegion.DesiredSize.Height - LogPanel.DesiredSize.Height; // label row + gaps + margin + var spare = RootGrid.Bounds.Height + - UpperStack.DesiredSize.Height + - CountdownBar.DesiredSize.Height + - chrome; + + if (spare <= LogContentMaxHeight) + { + ResetFlightLogHeight(); + return; + } + + // Sub-pixel drift isn't worth a whole layout pass to chase. + if (Math.Abs(spare - LogPanel.Height) < 1) return; + LogPanel.MaxHeight = double.PositiveInfinity; + LogPanel.Height = spare; + } + + /// Back to the redesign's content-sized panel: 104px, growing with the lines to 150px. + private void ResetFlightLogHeight() + { + LogPanel.Height = double.NaN; + LogPanel.MaxHeight = LogContentMaxHeight; + } + + private async void OnCopyLogClick(object? sender, RoutedEventArgs e) => await CopyFlightLogAsync(); + + private async void OnSaveLogClick(object? sender, RoutedEventArgs e) => await SaveFlightLogAsync(); + + /// + /// Copies the whole flight log to the clipboard as plain text — the panel shows only its last few + /// lines, and a launch that went wrong is worth pasting somewhere. Best-effort: a missing or throwing + /// clipboard is reported, never crashes the async-void click. Internal for tests. + /// + internal async Task CopyFlightLogAsync() + { + var text = _vm.LogText; + if (text.Length == 0) return; + var lines = _vm.Log.Count; + + var clipboard = Clipboard; + if (clipboard is null) + { + _vm.AppendLog("⚠ Clipboard unavailable — couldn't copy the flight log."); + return; + } + + try + { + await clipboard.SetTextAsync(text); + _vm.AppendLog($"📋 Copied {lines} flight-log line(s) to the clipboard."); + } + catch (Exception ex) + { + _vm.AppendLog($"⚠ Couldn't copy the flight log: {ex.Message}"); + } + } + + /// + /// Saves the flight log to a text file the user picks. Cancelling the picker is silent — nothing was + /// asked for; a save that fails says so in the log itself. Internal for tests. + /// + internal async Task SaveFlightLogAsync() + { + var text = _vm.LogText; + if (text.Length == 0) return; + + string? path; + try + { + path = await _dialogs.PickFlightLogPathAsync($"fido-flight-log-{DateTime.Now:yyyyMMdd-HHmmss}.txt"); + } + catch (Exception ex) + { + _vm.AppendLog($"⚠ Couldn't open the save dialog: {ex.Message}"); + return; + } + if (string.IsNullOrWhiteSpace(path)) return; + + try + { + await File.WriteAllTextAsync(path, text + Environment.NewLine); + _vm.AppendLog($"✓ Flight log saved to {path}"); + } + catch (Exception ex) + { + _vm.AppendLog($"⚠ Couldn't save the flight log: {ex.Message}"); + } + } + // --- Default tool popover / settings --------------------------------------------------- /// Rebuilds the gear popover's radio list from config, ticking the persisted default. @@ -781,8 +910,6 @@ private void ReportUnknownTool(string slug) /// didn't match, and whether --folder asked the run to start on the Folder chip. private sealed record StartupPlan(bool BranchProvided, Editor? Tool, string? UnknownToolSlug, bool PreferFolder); - private void ScrollLogToEnd() => LogScroller.Offset = new Vector(0, LogScroller.Extent.Height); - // --- Auto-close ----------------------------------------------------------------------- /// diff --git a/tests/Fido.Tests/E2E/FlightLogTests.cs b/tests/Fido.Tests/E2E/FlightLogTests.cs new file mode 100644 index 0000000..56fdf19 --- /dev/null +++ b/tests/Fido.Tests/E2E/FlightLogTests.cs @@ -0,0 +1,191 @@ +using System.IO; +using Avalonia.Controls; +using Avalonia.Input.Platform; +using Avalonia.Threading; +using Fido.Tests.Infrastructure; + +namespace Fido.Tests.E2E; + +/// +/// The flight log at the bottom of the main screen: it takes whatever vertical room the window has +/// spare (so a taller window means a taller log, not a gap above it), and its narration can be lifted +/// out — the whole log to the clipboard, or saved to a text file the user picks. +/// +[NotInParallel] +public class FlightLogTests +{ + [Test] + public async Task Copying_the_flight_log_puts_every_line_on_the_clipboard() + { + using var world = new TestRepoWorld(); + var origin = world.CreateOrigin("Foo", "Foo"); + var root = world.SearchRoot("root"); + world.Clone(origin, root, "Foo"); + + var services = world.BuildServices([root], new FakeEditorLauncher(), new FakeDialogService()); + + await Harness.WithWindow(services, async window => + { + await window.Discover("main"); + var lines = window.Vm().Log.Count; + await Assert.That(lines).IsGreaterThan(1); + + await window.CopyFlightLogAsync(); + + var clipboard = TopLevel.GetTopLevel(window)?.Clipboard; + await Assert.That(clipboard).IsNotNull(); + var copied = await clipboard!.TryGetTextAsync(); + + // Everything the panel had — the mission-control preamble through the scan result. + await Assert.That(copied).IsNotNull(); + await Assert.That(copied!).Contains("🚀 Going around the horn…"); + await Assert.That(copied).Contains("✓ Found 1 location(s) for 'main'."); + await Assert.That(copied.Split('\n').Length).IsEqualTo(lines); + + // The copy itself is narrated, and doesn't ride along in what was copied. + await Assert.That(window.LogText()).Contains($"📋 Copied {lines} flight-log line(s) to the clipboard."); + await Assert.That(copied.Contains("flight-log line(s)")).IsFalse(); + }); + } + + [Test] + public async Task An_empty_flight_log_has_nothing_to_copy_or_save() + { + using var world = new TestRepoWorld(); + var root = world.SearchRoot("root"); + var dialogs = new FakeDialogService(); + var services = world.BuildServices([root], new FakeEditorLauncher(), dialogs); + + await Harness.WithWindow(services, async window => + { + // Idle: nothing has been scanned, so the log is empty and both actions are disabled. + await Assert.That(window.Vm().HasLog).IsFalse(); + await Assert.That(window.FindControl public Func OnConfirmForceDelete { get; set; } = _ => false; + /// + /// Flight-log save responder, handed the suggested file name; defaults to returning null — the user + /// cancelled the picker. Return a path to have the log written there. + /// + public Func OnPickFlightLogPath { get; set; } = _ => null; + public List ForceDeleteConfirmations { get; } = new(); + + /// Every suggested file name the flight-log save picker was opened with, in order. + public List FlightLogSaveRequests { get; } = new(); + public int SettingsShownCount { get; private set; } public Task ConfirmForceDeleteWorktreeFolderAsync(WorktreeForceDelete request) @@ -31,4 +41,10 @@ public Task ShowSettingsAsync(AppConfig config, ConfigService configService) SettingsShownCount++; return Task.CompletedTask; } + + public Task PickFlightLogPathAsync(string suggestedFileName) + { + FlightLogSaveRequests.Add(suggestedFileName); + return Task.FromResult(OnPickFlightLogPath(suggestedFileName)); + } } From 16e541e7b913b0210e1974cdc44a5f9b519d965f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:50:18 +0000 Subject: [PATCH 2/2] Await the startup scan in tests instead of racing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CLI startup tests were coin tosses, and the extra layout work the flight-log sizing does was enough to flip them: Ubuntu CI failed on `Unknown_tool_id_warns_with_known_ids_and_never_auto_opens` and `Branch_plus_tool_auto_opens_once_for_a_single_location_and_closes`, both of which also fail on main when the timing lands that way. The cause is one race with two faces. The Opened handler starts the CLI branch's scan fire-and-forget, and the harness shows the window and pumps the dispatcher before the test body runs — so that scan can complete first. When it does, it has already consumed the run's one-shots: the test's own `RunDiscoveryAsync` then clears the log the unknown-tool warning was just written into, and the auto-open's close has already fired before the body subscribes to `Closed`. The window now keeps the startup scan as an internal `StartupScan` task and the tests await it rather than starting a competing scan, so exactly one scan runs whatever the timing. For the close, `Harness.WithWindow` takes a `beforeShow` hook, letting that test watch for the close from before the window is shown. No production behaviour changes — the scan is the same fire-and-forget launch, now with a handle on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019pNcGRSKiKZiZZWp7zDP7V --- src/Views/MainWindow.axaml.cs | 9 ++++++- .../E2E/StartupAndValidationTests.cs | 26 ++++++++++--------- tests/Fido.Tests/Infrastructure/Harness.cs | 11 ++++++-- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/Views/MainWindow.axaml.cs b/src/Views/MainWindow.axaml.cs index 8d5516b..da92712 100644 --- a/src/Views/MainWindow.axaml.cs +++ b/src/Views/MainWindow.axaml.cs @@ -63,6 +63,13 @@ public partial class MainWindow : Window /// Live while a post-launch auto-close countdown is running; cancelling it aborts the close. private CancellationTokenSource? _closeCountdown; + /// + /// The discovery scan a CLI-supplied branch kicks off on open. Tests await this instead of starting a + /// scan of their own: whichever scan lands first consumes the run's one-shots (the auto-open, the + /// unknown-tool report) and a superseding scan clears the log, so racing it is a coin toss. + /// + internal Task StartupScan { get; private set; } = Task.CompletedTask; + public MainWindow() : this(FidoServices.CreateDefault()) { } @@ -134,7 +141,7 @@ internal MainWindow(FidoServices services) _autoOpenTool = startup.Tool; _startupUnknownToolSlug = startup.UnknownToolSlug; _startupPreferFolder = startup.PreferFolder; - _ = RunDiscoveryAsync(); + StartupScan = RunDiscoveryAsync(); } }; } diff --git a/tests/Fido.Tests/E2E/StartupAndValidationTests.cs b/tests/Fido.Tests/E2E/StartupAndValidationTests.cs index 30ea665..82c002b 100644 --- a/tests/Fido.Tests/E2E/StartupAndValidationTests.cs +++ b/tests/Fido.Tests/E2E/StartupAndValidationTests.cs @@ -38,10 +38,10 @@ await Harness.WithWindow(services, async window => var vm = window.Vm(); await Assert.That(vm.BranchName).IsEqualTo("main"); - // Run the scan to completion deterministically (the Opened handler's own scan is - // fire-and-forget; ours supersedes it). One location — but with no tool named on - // the command line, presenting the result is all that happens. - await window.RunDiscoveryAsync(); + // Await the scan the Opened handler started, rather than racing it with one of our + // own. One location — but with no tool named on the command line, presenting the + // result is all that happens. + await window.StartupScan; await Assert.That(vm.Phase).IsEqualTo(DiscoveryPhase.Found); await Assert.That(vm.Targets.Count).IsEqualTo(1); @@ -69,11 +69,13 @@ public async Task Branch_plus_tool_auto_opens_once_for_a_single_location_and_clo Program.StartupArgs = ["main", "rider"]; // bare branch, then a bare tool id try { + // Watch for the close from before the window is shown: the startup scan can find its one + // location, open Rider, and close Fido while Show() is still running — a subscription made + // inside the body would then be waiting for a close that already happened. + var closed = new TaskCompletionSource(); + await Harness.WithWindow(services, async window => { - var closed = new TaskCompletionSource(); - window.Closed += (_, _) => closed.TrySetResult(); - // No interaction: naming a tool on the CLI auto-opens when the scan finds one location. var launched = await Task.WhenAny(launcher.FirstLaunch, Task.Delay(TimeSpan.FromSeconds(10))); await Assert.That(launched).IsEqualTo((Task)launcher.FirstLaunch); @@ -83,7 +85,7 @@ await Harness.WithWindow(services, async window => // ...and a CLI-driven launch closes Fido (CloseAfterOpen.CommandLine, no delay). var didClose = await Task.WhenAny(closed.Task, Task.Delay(TimeSpan.FromSeconds(10))); await Assert.That(didClose).IsEqualTo((Task)closed.Task); - }); + }, beforeShow: window => window.Closed += (_, _) => closed.TrySetResult()); } finally { @@ -110,7 +112,7 @@ public async Task Two_locations_with_an_explicit_tool_lists_both_and_does_not_au await Harness.WithWindow(services, async window => { var vm = window.Vm(); - await window.RunDiscoveryAsync(); // deterministic re-run of the startup scan + await window.StartupScan; // the scan the Opened handler started, awaited to completion // Both locations are presented as cards for the user to disambiguate... await Assert.That(vm.Phase).IsEqualTo(DiscoveryPhase.Found); @@ -153,9 +155,9 @@ await Harness.WithWindow(services, async window => var vm = window.Vm(); // The branch still scans (--branch prefills AND scans, per the handoff); the typo - // only disarms the one-shot auto-open. Await the flow deterministically — this call - // supersedes the Opened handler's fire-and-forget scan and inherits its one-shots. - await window.RunDiscoveryAsync(); + // only disarms the one-shot auto-open. Await that very scan: starting a second one + // would clear the log the first had already written the warning into. + await window.StartupScan; // The typo is reported after the scan (which resets the log), listing the ids that // would have worked; the branch stays prefilled so the user can correct and retry. diff --git a/tests/Fido.Tests/Infrastructure/Harness.cs b/tests/Fido.Tests/Infrastructure/Harness.cs index 9d6d3f8..12c7f1e 100644 --- a/tests/Fido.Tests/Infrastructure/Harness.cs +++ b/tests/Fido.Tests/Infrastructure/Harness.cs @@ -25,11 +25,18 @@ public static Task OnUi(Func body) => } }); - /// Builds and shows a real MainWindow with the injected services, runs , then closes it. - public static Task WithWindow(FidoServices services, Func body) => + /// + /// Builds and shows a real MainWindow with the injected services, runs , then + /// closes it. sees the window before it is shown — the hook for anything + /// that must be watched from the first frame, since a CLI-driven run can launch its tool and close the + /// window while the window is being shown, before is ever reached. + /// + public static Task WithWindow(FidoServices services, Func body, + Action? beforeShow = null) => Ui.On(async () => { var window = new MainWindow(services); + beforeShow?.Invoke(window); window.Show(); UiTestExtensions.Pump(); try