using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Threading; using Avalonia.VisualTree; using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.App.Views; using DodoSSH.Client.Session; using DodoSSH.Client.Session.Tests; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using DodoSSH.Crypto; using NSubstitute; namespace DodoSSH.Client.App.Layout.Tests; /// /// How the quick-connect palette answers a keyboard and a pointer. /// /// /// /// This is the suite the palette shipped without, and the reason it shipped without one is that all of this /// used to live on MainWindow — which cannot be shown here at all, because attaching the terminal's /// WebView initialises WebView2 on a thread it refuses. See /// . A UserControl hosts in a bare /// window, takes real key and pointer input, and can therefore be held to what it promises. /// /// /// Three things were wrong and each has a test here: nothing answered a press outside the palette, so the one /// gesture everybody tries first did nothing; the caret never reached the query box, because the window /// focused it from the view model's PropertyChanged — ahead of the binding that reveals the control, /// and focus on a collapsed control is a no-op; and the keys were answered only by a handler on the window, /// which anything on the route could have taken first. /// /// /// A real over a real unlocked vault, for the same reason the layout suite /// uses one: compiled bindings resolve against the declared type, and the palette's list is populated by the /// vault's own hosts. Nothing here reaches a network — the connect the Enter test performs fails inside the /// vault's own error handling, which is fine, because what Enter promises is to take the highlighted result /// and close. /// /// public sealed class QuickConnectTests : IAsyncLifetime { private const string Passphrase = "a sufficiently long passphrase"; private const string ServerUrl = "https://dodossh.example"; /// Far below the shipped profile: nothing here attacks a wrap. private static readonly Argon2Profile CheapProfile = Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); private readonly FakeAccountServer server = new(); private readonly StubKeyBinding keyBinding = new(); private readonly VaultKnownHostStore knownHosts = new(); private ClientCacheFactory caches = null!; private TerminalWorkspace workspace = null!; private VaultSession session = null!; private VaultViewModel vault = null!; private MainWindowViewModel shell = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() { caches = ClientCacheFactory.ForMemory($"palette-{Guid.CreateVersion7():N}"); await caches.MigrateAsync(Token); await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile) .EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token); var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); session = outcome.Session!; workspace = new TerminalWorkspace( new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)), Substitute.For(), TimeProvider.System); await knownHosts.OpenAsync(session, Token); vault = new VaultViewModel(session, workspace, knownHosts, static () => null); await SeedAsync(); shell = new MainWindowViewModel( ClientPaths.Default, caches, workspace, knownHosts, Substitute.For(), (_, _) => throw new NotSupportedException("nothing here signs in"), TimeProvider.System, Substitute.For(), CheapProfile) { // The state the palette is only ever open in. Assigned rather than reached through the unlock // path, which would be a second enrollment and a second Argon2 pass for no extra coverage. State = ShellState.Unlocked, Vault = vault, }; } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync(); knownHosts.Close(); await workspace.DisposeAsync(); await session.DisposeAsync(); caches.Dispose(); } /// /// The gesture everybody tries first, and the one that did nothing at all: the wash took no pointer input, /// so the only ways out of the palette were a key and the button that opened it. /// [Fact] public async Task APressOnTheWashClosesThePalette() { await OnThePaletteAsync((_, window) => { // The bottom-left corner: the card is 520 wide, centred, and starts 90 pixels down, so nothing // here belongs to it. window.MouseDown(new Point(12, 520), MouseButton.Left); shell.IsSearching.ShouldBeFalse(); }); } /// /// The other half of the same rule, and the one that makes it worth a handler rather than a press anywhere /// closing: a press on the card bubbles through the wash on its way out, so a handler that did not check /// where the press started would close the palette the moment somebody clicked into the box. /// [Fact] public async Task APressOnTheCardDoesNotClose() { await OnThePaletteAsync((palette, window) => { window.MouseDown(Centre(palette.QueryBox, window), MouseButton.Left); shell.IsSearching.ShouldBeTrue(); }); } [Fact] public async Task EscapeClosesThePalette() { await OnThePaletteAsync((palette, window) => { palette.QueryBox.Focus().ShouldBeTrue(); window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None); shell.IsSearching.ShouldBeFalse(); }); } /// /// The second assertion is the whole reason the selection is moved by hand rather than by letting the list /// take focus: a palette whose arrow keys moved the caret out of the query box would stop receiving the /// next character typed. /// [Fact] public async Task TheArrowsMoveTheSelectionAndLeaveTheKeyboardInTheBox() { await OnThePaletteAsync((palette, window) => { palette.QueryBox.Focus().ShouldBeTrue(); shell.SearchResults.Count.ShouldBeGreaterThan(2, "an empty list would prove nothing"); shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]); window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); shell.SelectedSearchResult.ShouldBe(shell.SearchResults[1]); window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None); shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]); // Clamped rather than wrapped, which is the palette's own rule. window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None); shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]); palette.QueryBox.IsFocused.ShouldBeTrue("the arrows must not move the caret out of the box"); }); } /// /// What Enter promises is to take the highlighted row: the palette closes and the vault is pointed at that /// host. The connection it then asks for fails in this suite — there is no server and no shell — and it /// fails inside the vault's own handling, which is the point of connecting through the vault's command /// rather than opening a session from the palette. /// [Fact] public async Task EnterTakesTheHighlightedResult() { await OnThePaletteAsync((palette, window) => { palette.QueryBox.Focus().ShouldBeTrue(); window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); var highlighted = shell.SelectedSearchResult.ShouldNotBeNull(); window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); shell.IsSearching.ShouldBeFalse(); vault.SelectedHost?.EntityId.ShouldBe(highlighted.EntityId); }); } /// /// The palette is a box somebody is expected to start typing into, and for a while it was not: the window /// focused it from the view model's PropertyChanged, which runs before the binding that reveals the /// control, and Focus() on a collapsed control is a no-op that is never replayed. Becoming visible /// is the moment that cannot be too early, so that is where the palette takes the keyboard — and this is /// the test that says so. /// [Fact] public async Task ThePaletteTakesTheKeyboardWhenItAppears() { await LayoutHarness.OnTheUiThreadAsync( () => { var elsewhere = new TextBox(); var palette = new QuickConnect { DataContext = shell, IsVisible = false }; var window = new Window { Content = new Panel { Children = { elsewhere, palette } } }; LayoutHarness.Settle(window, 900, 600); try { elsewhere.Focus().ShouldBeTrue(); shell.ToggleSearchCommand.Execute(null); palette.IsVisible = true; // The layout pass the application's dispatcher would run anyway. Without it the query box // is not in the visual tree yet, which is the whole reason the palette defers this. Dispatcher.UIThread.RunJobs(); palette.QueryBox.IsFocused.ShouldBeTrue(); } finally { window.Close(); } }, Token); } // ---- Helpers ---- /// Opens the palette in a window the size the application's is, and runs one body against it. private Task OnThePaletteAsync(Action body) => LayoutHarness.OnTheUiThreadAsync( () => { shell.ToggleSearchCommand.Execute(null); shell.IsSearching.ShouldBeTrue("every case here starts with the palette open"); var palette = new QuickConnect { DataContext = shell }; var window = new Window { Content = palette }; LayoutHarness.Settle(window, 900, 600); try { body(palette, window); } finally { window.Close(); } }, Token); private static Point Centre(Visual control, Visual window) => control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window) ?? throw new InvalidOperationException("the control is not in this window's tree"); /// Enough hosts that the arrow keys have somewhere to go. private async Task SeedAsync() { for (var i = 0; i < 6; i++) { vault.NewHostCommand.Execute(null); vault.EditorLabel = $"host-{i}"; vault.EditorHostname = $"host-{i}.internal"; vault.EditorUsername = "deploy"; await vault.SaveHostCommand.ExecuteAsync(null); } await vault.LoadAsync(Token); } }