Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s

Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
This commit is contained in:
2026-07-31 08:39:37 +02:00
parent d162271a45
commit 9a76eced14
37 changed files with 4672 additions and 1347 deletions
@@ -31,27 +31,35 @@ internal static class LayoutHarness
/// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
/// than the one a user is allowed to drag to.
/// </remarks>
internal const double MinimumWidth = 820;
internal const double MinimumWidth = 880;
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 520;
internal const double MinimumHeight = 560;
/// <summary>The vault column's fixed width, from <c>MainWindow</c>'s <c>ColumnDefinitions</c>.</summary>
internal const double VaultColumnWidth = 340;
/// <summary>The host sidebar's fixed width, from the hosts screen's <c>ColumnDefinitions</c>.</summary>
internal const double HostSidebarWidth = 268;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 54;
/// <summary>
/// What the account bar takes off the top before the column gets any height at all.
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
/// </summary>
/// <remarks>
/// The bar is <c>Padding="12,8"</c> around a row whose tallest child is a themed <see cref="Button"/>, so
/// its height is the button's plus sixteen. Stated as a constant with a test holding the button to
/// thirty-two rather than measured from the bar itself, because measuring the bar would mean showing
/// <c>MainWindow</c>, and that cannot be done here at all — see the harness's own tests.
/// Both are fixed heights declared in their own markup — 38 and 24 — rather than shapes that grow with
/// their contents, which is what makes stating them here honest. Two tests hold the two controls to
/// those numbers, so the budget below cannot drift away from what the window actually leaves.
/// </remarks>
internal const double AccountBarHeight = 48;
internal const double TitleBarHeight = 38;
/// <summary>The height the column actually gets at the window's minimum.</summary>
internal static double VaultColumnHeight => MinimumHeight - AccountBarHeight;
/// <inheritdoc cref="TitleBarHeight" />
internal const double StatusBarHeight = 24;
/// <summary>The height a screen actually gets at the window's minimum.</summary>
internal static double ScreenHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
private static readonly HeadlessUnitTestSession Session =
HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
@@ -140,9 +140,9 @@ public sealed class LayoutHarnessTests
// A HeadlessUnitTestSession owns its dispatcher thread and does not offer an apartment choice, so
// the whole window cannot be laid out here at any size.
//
// That is the reason this harness measures VaultColumn rather than MainWindow: the column is the
// part with a height budget to blow, and it has no native child window in it. If a future Avalonia
// makes the adapter lazy, this test starts failing and the harness can be widened.
// That is the reason this harness measures the extracted controls rather than MainWindow: each of
// them is a part with a height budget to blow, and none of them has a native child window in it. If a
// future Avalonia makes the adapter lazy, this test starts failing and the harness can be widened.
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
@@ -170,7 +170,7 @@ public sealed class LayoutHarnessTests
[Fact]
public async Task TheHarnessMeasuresTheSizeTheWindowDeclares()
{
// Pins the two constants against the XAML. A harness measuring 820x520 while the window lets itself
// Pins the two constants against the XAML. A harness measuring a size the window lets itself
// be dragged to something smaller would be certifying a size no user is held to.
await LayoutHarness.OnTheUiThreadAsync(
() =>
@@ -0,0 +1,470 @@
using Avalonia.Controls;
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;
/// <summary>
/// Whether each screen fits in the space the window gives it.
/// </summary>
/// <remarks>
/// <para>
/// This suite used to measure one control, <c>VaultColumn</c>, because there was one. The design import
/// split it in two — the host list lives beside the terminal, and everything else in the vault has a screen
/// of its own — and added a titlebar, a nav rail and a status bar. That is five things to measure, and the
/// split is what keeps every one of them measurable: none contains the terminal's WebView, and
/// <c>MainWindow</c> still cannot be laid out here at all, because WebView2's adapter refuses the headless
/// dispatcher's MTA thread. <see cref="LayoutHarnessTests"/> pins that.
/// </para>
/// <para>
/// One test per shape a user can put a screen into, because a shape that is never laid out is a shape never
/// checked. The host sidebar has three — list, list with the editor open, and list folded away — and the
/// vault screen has one per category plus one per editor.
/// </para>
/// <para>
/// A real <c>VaultViewModel</c> over a real unlocked vault, rather than a stand-in. Compiled bindings
/// resolve against the declared data type, so a stand-in would have to be the same type anyway — and the
/// editors' height depends on real content: a key with a real armour block in the box is taller than an
/// empty one.
/// </para>
/// </remarks>
public sealed class ScreenLayoutTests : 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 static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"layout-{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!;
// Never started and never connected through: no screen's layout depends on the terminal, and the
// substitute is here only because the view model's constructor asks for one.
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
// Offline. A null connection is what these screens show on a laptop with no network, and it keeps
// every sync pass out of a suite that is only measuring rectangles.
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
await SeedAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
// ---- The host sidebar ----
[Fact]
public async Task TheHostSidebarFitsWithNoEditorOpen()
{
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The tight one, and the reason this suite still exists. The sidebar is 268 pixels wide against the old
/// column's 340, and the host editor is the tallest thing in it: six fields, an authentication picker
/// with a two-line item template, a checkbox, a paragraph of hint text and three buttons, all sharing a
/// column with the list above them.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithItsEditorOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorAuthenticationChoices.Count
.ShouldBeGreaterThan(1, "the picker has to be populated for this to measure anything");
// Measured with a credential selected, because an empty picker is shorter than one showing a
// qualifier beside a label.
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// Folding the list away is the one thing a user can do to this control that changes which of its parts
/// is on screen, so it is a shape worth laying out on its own.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithItsListFoldedAway()
{
vault.ToggleHostsCommand.Execute(null);
vault.AreHostsExpanded.ShouldBeFalse();
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes
/// the keyboard off the terminal's native child window first and then focuses this target, so a target
/// that cannot take focus leaves the user with no focused element and no way back except the mouse.
/// </para>
/// <para>
/// Which is why this asserts that focus was <i>taken</i> rather than that the right control was named.
/// A <c>ListBox</c> is not focusable by default, so the call returns false against a list that has not
/// asked to be — and <c>Focus()</c> on a collapsed control is a no-op that is not replayed when it is
/// revealed, which is exactly what the folded-away case would hit.
/// </para>
/// </remarks>
[Fact]
public async Task TheSidebarsKeyboardTargetTakesFocusInBothOfItsShapes()
{
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.KeyboardTarget.ShouldBeSameAs(sidebar.HostList);
sidebar.KeyboardTarget.Focus().ShouldBeTrue("the list is showing");
});
vault.ToggleHostsCommand.Execute(null);
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.KeyboardTarget.ShouldBeSameAs(sidebar.HostFilter);
sidebar.KeyboardTarget.Focus().ShouldBeTrue("the list is folded away, so the filter takes it");
});
}
/// <remarks>
/// The editor open with the list still on screen behind it, which is the state a user is most likely to
/// leave the sidebar in — so it is the state the keyboard answer most has to hold in.
/// </remarks>
[Fact]
public async Task TheSidebarsKeyboardTargetStillTakesFocusWithTheEditorOpen()
{
vault.NewHostCommand.Execute(null);
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.HostList.IsEffectivelyVisible.ShouldBeTrue();
sidebar.KeyboardTarget.Focus().ShouldBeTrue();
});
}
// ---- The vault screen ----
[Fact]
public async Task TheVaultScreenFitsInEveryCategory()
{
foreach (var section in new[]
{
VaultSection.All, VaultSection.Keys, VaultSection.Credentials, VaultSection.KnownHosts,
})
{
vault.Section = section;
await MeasureVaultAsync(faults => faults.ShouldBeEmpty($"the {section} category"));
}
}
/// <remarks>
/// The tall one: a private key needs a real text area, and the vault screen's detail pane is 244 pixels
/// wide — the narrowest column any form in this application has to fit into.
/// </remarks>
[Fact]
public async Task TheVaultScreenFitsWithTheKeyEditorOpen()
{
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
vault.ShowsKeys.ShouldBeTrue("opening an editor has to bring its own category into view");
vault.KeyEditorPrivateKey = string.Join(
'\n',
Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6));
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheVaultScreenFitsWithThePasswordEditorOpen()
{
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue();
vault.ShowsCredentials.ShouldBeTrue("opening an editor has to bring its own category into view");
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The detail pane with something selected, which is what the design's right-hand column is really about
/// — and the pin is the one carrying a full fingerprint on a wrapped monospace line.
/// </remarks>
[Fact]
public async Task TheVaultScreenFitsWithAPinSelected()
{
vault.Section = VaultSection.KnownHosts;
vault.VaultItems.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
vault.SelectedVaultItem = vault.VaultItems[0];
vault.SelectedItemIsPin.ShouldBeTrue();
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The rail is the only way to reach a category, so a button that lands on nothing walls off three
/// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they
/// are the size a pointer can find, which a zero-height row in a collapsed border would not be.
/// </remarks>
[Fact]
public async Task TheCategoryRailIsBigEnoughToClick()
{
await OnTheVaultAsync((screen, _) =>
{
var buttons = screen.GetVisualDescendants()
.OfType<Button>()
.Where(button => button.Classes.Contains("cat"))
.ToList();
buttons.Count.ShouldBe(4, "one per category that exists");
foreach (var button in buttons)
{
button.Bounds.Height.ShouldBeGreaterThan(20);
button.Bounds.Width.ShouldBeGreaterThan(120);
}
});
}
// ---- The chrome ----
/// <remarks>
/// <para>
/// The two constants the whole height budget is subtracted from, held against the markup that declares
/// them. If either bar grows, every screen gets less room than this suite thinks it does and every
/// measurement above quietly becomes optimistic.
/// </para>
/// <para>
/// Laid out with no data context, which is the point: these are fixed-height strips and their geometry
/// must not depend on what is bound into them. A binding that made one of them grow with its contents
/// would fail here.
/// </para>
/// </remarks>
[Fact]
public async Task TheChromeIsTheHeightTheBudgetAssumes()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var titleBar = new TitleBar();
var titleWindow = LayoutHarness.HostAtMinimumSize(
titleBar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
try
{
titleBar.Bounds.Height.ShouldBe(LayoutHarness.TitleBarHeight);
LayoutHarness.Unreachable(titleWindow).ShouldBeEmpty();
}
finally
{
titleWindow.Close();
}
var statusBar = new StatusBar();
var statusWindow = LayoutHarness.HostAtMinimumSize(
statusBar, LayoutHarness.MinimumWidth, LayoutHarness.StatusBarHeight);
try
{
statusBar.Bounds.Height.ShouldBe(LayoutHarness.StatusBarHeight);
}
finally
{
statusWindow.Close();
}
},
Token);
}
/// <remarks>
/// Five destinations in a 54-pixel column. The rail runs vertically, so what runs out here is height
/// rather than width — at the window's minimum the five entries have to leave room for each other, which
/// is the same failure the old four-button selector was one label away from.
/// </remarks>
[Fact]
public async Task TheNavRailHoldsFiveDestinationsAtTheWindowsMinimum()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var rail = new NavRail();
var window = LayoutHarness.HostAtMinimumSize(
rail, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
try
{
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
buttons.Count.ShouldBe(5, "one per screen the rail reaches");
foreach (var button in buttons)
{
button.Bounds.Height.ShouldBeGreaterThan(20);
// One pixel narrower than the rail, because the rail draws its own divider down its
// right edge and that comes out of the content. Stated exactly rather than as a
// lower bound: a button that stopped filling the rail would leave a dead strip
// beside every destination, which is precisely the kind of near-miss a bound hides.
button.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1);
}
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
// ---- Helpers ----
/// <summary>Lays the sidebar out at the width the hosts screen gives it.</summary>
private Task MeasureSidebarAsync(Action<IReadOnlyList<string>> assert) =>
OnTheSidebarAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
private Task OnTheSidebarAsync(Action<HostSidebar, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var sidebar = new HostSidebar { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.HostSidebarWidth, LayoutHarness.ScreenHeight);
try
{
body(sidebar, window);
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the vault screen out at the width it gets once the nav rail has taken its column.</summary>
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
private Task OnTheVaultAsync(Action<VaultScreen, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new VaultScreen { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
body(screen, window);
}
finally
{
window.Close();
}
},
Token);
/// <remarks>
/// Enough rows in every list that none is empty, because an empty list is the easiest case and the one
/// least worth certifying.
/// </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);
}
for (var i = 0; i < 4; i++)
{
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = $"key-{i}";
vault.KeyEditorPrivateKey =
$"-----BEGIN OPENSSH PRIVATE KEY-----\nMATERIAL-{i}\n-----END OPENSSH PRIVATE KEY-----\n";
await vault.SaveKeyCommand.ExecuteAsync(null);
}
for (var i = 0; i < 3; i++)
{
vault.NewCredentialCommand.Execute(null);
vault.CredentialEditorLabel = $"credential-{i}";
vault.CredentialEditorPassword = $"password-{i}";
vault.CredentialEditorUsername = $"account-{i}";
await vault.SaveCredentialCommand.ExecuteAsync(null);
}
// Pins come from approving a fingerprint at connect time, not from an editor, so they are seeded
// through the store the connect path writes to. Two for one endpoint, because a host offering keys
// of two algorithms is ordinary and the duplicate is one of the things this list has to show.
foreach (var (host, algorithm) in new[]
{
("host-0.internal", "ssh-ed25519"),
("host-0.internal", "ecdsa-sha2-nistp256"),
("gone.internal", "ssh-ed25519"),
})
{
await knownHosts.TrustAsync(
new HostKeyPresentation(
host, 22, algorithm, $"SHA256:{algorithm}-fingerprint-0123456789abcdefghijklmnop"),
Token);
}
// Back to where the vault screen opens, so every test starts from the state a user would see.
vault.Section = VaultSection.All;
await vault.LoadAsync(Token);
}
}
@@ -1,435 +0,0 @@
using Avalonia.Controls;
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;
/// <summary>
/// Whether the vault column fits in the space the window gives it.
/// </summary>
/// <remarks>
/// <para>
/// The column is 340 pixels wide and holds a list and an editor per item type, of which it shows one type at a
/// time. This suite is the measurement behind that arrangement: the column used to stack both types and keep
/// itself from clipping its own Save button with a state rule — one editor open at a time — and that rule was
/// added on the strength of an argument. The argument was right about the stacked column and is now moot,
/// which is a thing this suite found rather than assumed. See
/// <see cref="BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut" />.
/// </para>
/// <para>
/// One test per section, and one per section with its editor open, because that is the full set of shapes a
/// user can put this column into. A third section will add two more.
/// </para>
/// <para>
/// A real <c>VaultViewModel</c> over a real unlocked vault, rather than a stand-in. Compiled bindings resolve
/// against the declared data type, so a stand-in would have to be the same type anyway — and the editors'
/// height depends on real content: a key with a real armour block in the box is taller than an empty one.
/// </para>
/// </remarks>
public sealed class VaultColumnLayoutTests : 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 static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"layout-{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!;
// Never started and never connected through: the column's layout does not depend on the terminal, and
// the substitute is here only because the view model's constructor asks for one.
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
// Offline. A null connection is what the column shows on a laptop with no network, and it keeps every
// sync pass out of a suite that is only measuring rectangles.
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
await SeedAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task TheHostsSectionFitsWithNoEditorOpen()
{
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheHostsSectionFitsWithItsEditorOpen()
{
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheKeysSectionFitsWithNoEditorOpen()
{
vault.ShowSectionCommand.Execute(VaultSection.Keys);
vault.ShowsKeys.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheKeysSectionFitsWithItsEditorOpen()
{
// The tall one: a private key needs a real text area, and this editor is what the key list used to
// hide itself and cap its own height for. Both workarounds are gone, so this measurement is now the
// only thing saying they were not needed.
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
vault.ShowsKeys.ShouldBeTrue("opening an editor has to bring its own section into view");
vault.KeyEditorPrivateKey = string.Join(
'\n',
Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6));
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheCredentialsSectionFitsWithNoEditorOpen()
{
vault.ShowSectionCommand.Execute(VaultSection.Credentials);
vault.ShowsCredentials.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheCredentialsSectionFitsWithItsEditorOpen()
{
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue();
vault.ShowsCredentials.ShouldBeTrue("opening an editor has to bring its own section into view");
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The only section with no editor, so it has only the one shape — but the tallest rows, because each
/// carries a full fingerprint on a wrapped monospace line rather than a one-word description.
/// </remarks>
[Fact]
public async Task TheHostKeysSectionFits()
{
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
vault.ShowsKnownHosts.ShouldBeTrue();
vault.KnownHostPins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The host editor is the one a third item type made taller: its authentication picker is now a ComboBox
/// with a two-line-capable item template, and the section it sits in is the only one holding a
/// <c>NumericUpDown</c>, a <c>CheckBox</c> and two paragraphs of hint text. Measured with the picker
/// populated, because an empty ComboBox is shorter than one showing a qualifier beside a label.
/// </remarks>
[Fact]
public async Task TheHostEditorFitsWithTheAuthenticationPickerFull()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorAuthenticationChoices.Count
.ShouldBeGreaterThan(1, "the picker has to be populated for this to measure anything");
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut()
{
// This test used to assert the opposite, and its own comment said that if it ever started passing the
// rule it justified had become unnecessary. That has happened, and this is the record of it: the two
// editors are in different sections now and only one section is laid out, so the sizing argument for
// one-editor-at-a-time is dead.
//
// The rule itself is not, and AnEditorIsInTheWay says why — an open key editor holds a pasted private
// key, and moving on would leave it in a form nobody can see. That is a state rule with a state
// reason, so it belongs in the shell's tests and not here. This suite's job was the sizing claim, and
// the honest thing to do with a measurement that has flipped is to keep measuring it.
vault.IsEditing = true;
vault.IsEditingKey = true;
await MeasureAsync(faults => faults.ShouldBeEmpty(
"one section at a time means two open editors are never laid out together"));
vault.Section = VaultSection.Keys;
await MeasureAsync(faults => faults.ShouldBeEmpty(
"and the same holds from the other side, where the taller editor is the visible one"));
}
/// <remarks>
/// <para>
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes the
/// keyboard off the terminal's native child window first and then focuses this target, so a target that
/// cannot take focus leaves the user with no focused element and no way back except the mouse.
/// </para>
/// <para>
/// Which is why this asserts that focus was <i>taken</i> rather than that the right control was named.
/// Naming is the cheap half and it was already right; taking it was not — a <c>ListBox</c> is not focusable
/// by default, so this call returned false against the column as it stood and the shipped release-the-
/// keyboard path did nothing. Two ways to fail, and only the assertion that runs the call sees both: a
/// control in the section that is not showing is collapsed, and <c>Focus()</c> on a collapsed control is a
/// no-op that is not replayed when it is revealed.
/// </para>
/// </remarks>
[Fact]
public async Task TheKeyboardTargetIsTheListThatIsOnScreenAndItTakesFocus()
{
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.HostList);
column.KeyboardTarget.Focus().ShouldBeTrue("the hosts section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.Keys);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.KeyList);
column.KeyboardTarget.Focus().ShouldBeTrue("the keys section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.Credentials);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.CredentialList);
column.KeyboardTarget.Focus().ShouldBeTrue("the credentials section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.KnownHostList);
column.KeyboardTarget.Focus().ShouldBeTrue("the host keys section is showing");
});
}
/// <remarks>
/// The same call in the state the section rule allows: an editor open, its own list still on screen behind
/// it. The key list used to collapse itself whenever its editor opened, so a target that followed the
/// section would have been a no-op in exactly the state a user is most likely to leave the terminal in.
/// </remarks>
[Fact]
public async Task TheKeyboardTargetStillTakesFocusWithAnEditorOpen()
{
vault.NewKeyCommand.Execute(null);
await OnTheColumnAsync((column, _) =>
{
column.KeyList.IsEffectivelyVisible.ShouldBeTrue();
column.KeyboardTarget.Focus().ShouldBeTrue();
});
}
/// <remarks>
/// The claim the whole arrangement rests on, and the one nothing else here would notice breaking: two
/// sections left visible at once would overlap in the row they share rather than clip, so every fit test
/// above would still pass while the column showed one list through another.
/// </remarks>
[Fact]
public async Task OnlyOneSectionIsOnScreenAtOnce()
{
await AssertOnlyVisibleAsync(VaultSection.Hosts);
await AssertOnlyVisibleAsync(VaultSection.Keys);
await AssertOnlyVisibleAsync(VaultSection.Credentials);
await AssertOnlyVisibleAsync(VaultSection.KnownHosts);
}
/// <summary>Shows one section and checks that it is the only one a user can see.</summary>
private async Task AssertOnlyVisibleAsync(VaultSection section)
{
vault.Section = section;
await OnTheColumnAsync((column, _) =>
{
var lists = new Dictionary<VaultSection, ListBox>
{
[VaultSection.Hosts] = column.HostList,
[VaultSection.Keys] = column.KeyList,
[VaultSection.Credentials] = column.CredentialList,
[VaultSection.KnownHosts] = column.KnownHostList,
};
foreach (var (owner, list) in lists)
{
list.IsEffectivelyVisible.ShouldBe(
owner == section,
$"{owner} showing while {section} is selected");
}
});
}
/// <remarks>
/// The selector is the only way to reach a section, so a click that lands on nothing is a column with one
/// half of it walled off. Its buttons are covered by every fit test above — the harness treats a
/// <see cref="Button"/> as interactive — but that only proves they are inside the window. This proves they
/// are the size a pointer can find, which a zero-height row of buttons in a collapsed border would not be.
/// </remarks>
[Fact]
public async Task TheSelectorIsBigEnoughToClick()
{
await OnTheColumnAsync((column, _) =>
{
var buttons = column.SectionSelector.Children.OfType<Button>().ToList();
buttons.Count.ShouldBe(4, "one per section that exists");
foreach (var button in buttons)
{
button.Bounds.Height.ShouldBeGreaterThan(20);
button.Bounds.Width.ShouldBeGreaterThan(40);
}
// How much room a fifth section would have. The row is a horizontal StackPanel in a 340-pixel
// column, so the four labels are close to filling it — and the fit tests above would catch an
// overflow only as "a button falls outside the window", which reads as a mysterious layout fault
// rather than as "the selector has run out of room". Stated as a number so it reads as itself.
var used = buttons.Sum(button => button.Bounds.Width);
used.ShouldBeLessThan(
LayoutHarness.VaultColumnWidth,
$"the selector needs {used:0} of {LayoutHarness.VaultColumnWidth:0} pixels; a fifth section "
+ "means shorter labels or a second row");
});
}
/// <summary>Lays the column out at the size the window gives it and hands the faults to an assertion.</summary>
private Task MeasureAsync(Action<IReadOnlyList<string>> assert) =>
OnTheColumnAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
/// <summary>Shows the column at the size the window gives it and runs one body against it.</summary>
private Task OnTheColumnAsync(Action<VaultColumn, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var column = new VaultColumn { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
column,
LayoutHarness.VaultColumnWidth,
LayoutHarness.VaultColumnHeight);
try
{
body(column, window);
}
finally
{
window.Close();
}
},
Token);
/// <remarks>
/// Enough rows in both lists that neither is empty, because an empty list is the easiest case and the one
/// least worth certifying — and since the selector arrived, the keys section has a whole column of its own
/// to fill rather than a capped strip at the bottom of the hosts one.
/// </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);
}
for (var i = 0; i < 4; i++)
{
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = $"key-{i}";
vault.KeyEditorPrivateKey =
$"-----BEGIN OPENSSH PRIVATE KEY-----\nMATERIAL-{i}\n-----END OPENSSH PRIVATE KEY-----\n";
await vault.SaveKeyCommand.ExecuteAsync(null);
}
for (var i = 0; i < 3; i++)
{
vault.NewCredentialCommand.Execute(null);
vault.CredentialEditorLabel = $"credential-{i}";
vault.CredentialEditorPassword = $"password-{i}";
vault.CredentialEditorUsername = $"account-{i}";
await vault.SaveCredentialCommand.ExecuteAsync(null);
}
// Pins come from approving a fingerprint at connect time, not from an editor, so they are seeded
// through the store the connect path writes to. Two for one endpoint, because a host offering keys
// of two algorithms is ordinary and the duplicate is one of the things this list has to show.
foreach (var (host, algorithm) in new[]
{
("host-0.internal", "ssh-ed25519"),
("host-0.internal", "ecdsa-sha2-nistp256"),
("gone.internal", "ssh-ed25519"),
})
{
await knownHosts.TrustAsync(
new HostKeyPresentation(
host, 22, algorithm, $"SHA256:{algorithm}-fingerprint-0123456789abcdefghijklmnop"),
Token);
}
// Back to where the column opens, so every test starts from the state a user would see.
vault.Section = VaultSection.Hosts;
await vault.LoadAsync(Token);
}
}