using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.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,
// Never asked for a session: the palette searches the host list and connects through the
// vault's own command, and nothing on this screen transfers a file.
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);
}
}