Public Access
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
302 lines
12 KiB
C#
302 lines
12 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Session.Tests;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Crypto;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// How the quick-connect palette answers a keyboard and a pointer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This is the suite the palette shipped without, and the reason it shipped without one is that all of this
|
|
/// used to live on <c>MainWindow</c> — which cannot be shown here at all, because attaching the terminal's
|
|
/// WebView initialises WebView2 on a thread it refuses. See
|
|
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. A <c>UserControl</c> hosts in a bare
|
|
/// window, takes real key and pointer input, and can therefore be held to what it promises.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <c>PropertyChanged</c> — 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// A real <see cref="MainWindowViewModel"/> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class QuickConnectTests : IAsyncLifetime
|
|
{
|
|
private const string Passphrase = "a sufficiently long passphrase";
|
|
private const string ServerUrl = "https://dodossh.example";
|
|
|
|
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
|
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;
|
|
|
|
/// <inheritdoc />
|
|
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<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
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<IDeviceKeyStore>(),
|
|
(_, _) => 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<ISftpSessionFactory>(),
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
knownHosts.Close();
|
|
await workspace.DisposeAsync();
|
|
await session.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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 <c>PropertyChanged</c>, which runs before the binding that reveals the
|
|
/// control, and <c>Focus()</c> 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.
|
|
/// </remarks>
|
|
[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 ----
|
|
|
|
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
|
private Task OnThePaletteAsync(Action<QuickConnect, Window> 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");
|
|
|
|
/// <remarks>Enough hosts that the arrow keys have somewhere to go.</remarks>
|
|
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);
|
|
}
|
|
}
|