Public Access
Give hosts and terminals their own screen, and the rest of the vault another
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -997,25 +997,65 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
// ---- One kind of item at a time ----
|
||||
|
||||
/// <remarks>
|
||||
/// Hosts are not one of these any more. They have their own screen beside the terminal, which is what the
|
||||
/// design asks for and is the better split anyway: the host list is what you look at while you work, and
|
||||
/// the keys and passwords behind it are what you go and manage. What is left here is the vault screen's
|
||||
/// own rail, and it opens on everything at once because the categories are a filter over one table
|
||||
/// rather than four separate lists.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheColumnOpensOnHostsAndTheSelectorMovesBetweenSections()
|
||||
public async Task TheVaultScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
// Hosts, because connecting is what somebody who has just unlocked a vault came to do. Keys and
|
||||
// credentials exist to make that work, and neither is where the first click belongs.
|
||||
vault.Section.ShouldBe(VaultSection.Hosts);
|
||||
vault.ShowsHosts.ShouldBeTrue();
|
||||
vault.Section.ShouldBe(VaultSection.All);
|
||||
vault.ShowsAll.ShouldBeTrue();
|
||||
vault.ShowsKeys.ShouldBeFalse();
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Keys);
|
||||
|
||||
vault.ShowsKeys.ShouldBeTrue();
|
||||
vault.ShowsHosts.ShouldBeFalse("both flags are one fact read two ways and cannot both be true");
|
||||
vault.ShowsAll.ShouldBeFalse("both flags are one fact read two ways and cannot both be true");
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
||||
vault.ShowsHosts.ShouldBeTrue();
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
vault.ShowsAll.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The merged category is what the design's one credential table exists for, and the thing worth pinning
|
||||
/// about it is that it is a projection rather than a fifth list: every row maps back to the typed row the
|
||||
/// editors and the delete commands already act on.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheMergedTableCarriesEveryKindAndSelectingARowSelectsTheTypedOne()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
||||
|
||||
// Adding leaves the rail on whatever was added last, because opening an editor brings its own
|
||||
// category into view. Back to the merged one, which is where the screen opens.
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
|
||||
vault.ShowsAll.ShouldBeTrue();
|
||||
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy", "pg-primary"]);
|
||||
vault.VaultItems.Select(row => row.Type).ShouldBe(["SSH KEY", "PASSWORD"]);
|
||||
|
||||
vault.SelectedVaultItem = vault.VaultItems.First(row => row.Kind is VaultItemKind.Credential);
|
||||
|
||||
vault.SelectedCredential.ShouldNotBeNull();
|
||||
vault.SelectedCredential.Label.ShouldBe("pg-primary");
|
||||
vault.SelectedItemIsEditable.ShouldBeTrue();
|
||||
|
||||
// And narrowing to one kind does not disturb what is selected underneath.
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Keys);
|
||||
|
||||
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy"]);
|
||||
vault.SelectedCredential.Label.ShouldBe("pg-primary", "narrowing the view is not a deselection");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1032,33 +1072,34 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
|
||||
vault.Section = VaultSection.Hosts;
|
||||
vault.Section = VaultSection.All;
|
||||
vault.NewKeyCommand.Execute(null);
|
||||
vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open in the hosts section");
|
||||
vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open with the rail pointing elsewhere");
|
||||
vault.CancelKeyEditCommand.Execute(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.ShowsHosts.ShouldBeTrue();
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
|
||||
// And through the other door into each editor.
|
||||
// And through the other door into the editor.
|
||||
vault.Section = VaultSection.All;
|
||||
vault.SelectedKey = vault.Keys[0];
|
||||
vault.EditSelectedKeyCommand.Execute(null);
|
||||
vault.ShowsKeys.ShouldBeTrue();
|
||||
vault.CancelKeyEditCommand.Execute(null);
|
||||
|
||||
// The host editor is the exemption, and it is the point of the split: hosts are a screen of their
|
||||
// own, so opening their editor has no category to bring into view and must not move the rail.
|
||||
vault.Section = VaultSection.Keys;
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.ShowsHosts.ShouldBeTrue();
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.ShowsKeys.ShouldBeTrue("editing a host is not a reason to move the vault screen's rail");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The refusal that keeps the rule above true. Leaving the section while an editor is open would hide it,
|
||||
/// and in the key editor's case that means a pasted private key sitting in a form with nothing on screen
|
||||
/// to say it is there.
|
||||
/// The refusal that keeps the rule above true. Leaving the section while a vault-screen editor is open
|
||||
/// would hide it, and in the key editor's case that means a pasted private key sitting in a form with
|
||||
/// nothing on screen to say it is there.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SwitchingSectionIsRefusedWhileAnEditorIsOpen()
|
||||
public async Task SwitchingSectionIsRefusedWhileAVaultScreenEditorIsOpen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -1066,7 +1107,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.NewKeyCommand.Execute(null);
|
||||
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
|
||||
vault.ShowsKeys.ShouldBeTrue("the selector must not move away from an open editor");
|
||||
vault.Status.ShouldContain("SSH key");
|
||||
@@ -1074,18 +1115,29 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
// A refusal, not a lockout: dealing with the editor releases the selector.
|
||||
vault.CancelKeyEditCommand.Execute(null);
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
||||
vault.ShowsHosts.ShouldBeTrue();
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
vault.ShowsAll.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The host editor lives on a different screen from the rail, so it does not guard the rail — nothing
|
||||
/// about a half-typed host is visible or at risk from switching the vault screen's own category, and
|
||||
/// blocking it here used to leave three quarters of that screen inert with a message pointing at an
|
||||
/// editor the user could not see.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SwitchingSectionIsNotBlockedByAnOpenHostEditor()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
// Symmetrically, and with the message naming the editor that is actually open — which matters more
|
||||
// here than it used to, because the thing to go back to may not be the section on screen.
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "half-typed";
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Keys);
|
||||
|
||||
vault.ShowsHosts.ShouldBeTrue();
|
||||
vault.Status.ShouldContain("host");
|
||||
vault.ShowsKeys.ShouldBeTrue("a host editor on another screen has nothing to say about this rail");
|
||||
vault.IsEditing.ShouldBeTrue("switching category must not close the host editor either");
|
||||
vault.EditorLabel.ShouldBe("half-typed");
|
||||
}
|
||||
|
||||
@@ -1103,20 +1155,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.Status = string.Empty;
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
|
||||
vault.Status.ShouldBeEmpty();
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One editor at a time, still — but no longer for the reason it was introduced for. Both editors used to
|
||||
/// be <c>Auto</c> rows in one 340-pixel column whose combined height exceeded it; sections ended that, and
|
||||
/// the layout suite now measures two open editors fitting. What the rule buys today is that an open key
|
||||
/// editor is always one somebody can see, because it is holding their private key.
|
||||
/// One editor open at a time <em>within</em> the vault screen, still — but no longer across it and the
|
||||
/// Hosts screen. Both editors used to be <c>Auto</c> rows in one 340-pixel column whose combined height
|
||||
/// exceeded it; sections ended that, and the design import gave hosts their own screen, so the host
|
||||
/// editor no longer shares any column, any visibility or any risk with the key and credential editors.
|
||||
/// What the rule still buys, inside the vault screen, is that an open key editor is always one somebody
|
||||
/// can see, because it is holding their private key.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OnlyOneEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
|
||||
public async Task TheHostEditorAndAVaultScreenEditorCanBeOpenTogether()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -1126,7 +1180,30 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse("the host editor must not open over the key editor");
|
||||
// Both open at once: the host editor is on a screen of its own, and there is nothing for it to
|
||||
// clip or hide on the vault screen the key editor is on.
|
||||
vault.IsEditing.ShouldBeTrue("the host editor is a different screen's business now");
|
||||
vault.IsEditingKey.ShouldBeTrue("and does not close the vault screen's own editor");
|
||||
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One vault-screen editor at a time, still. The key and credential editors share the same screen and
|
||||
/// the same detail pane, so opening one over the other is exactly the case the rule exists for — unlike
|
||||
/// the host editor, which does not.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OnlyOneVaultScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewKeyCommand.Execute(null);
|
||||
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
|
||||
|
||||
vault.NewCredentialCommand.Execute(null);
|
||||
|
||||
vault.IsEditingCredential.ShouldBeFalse("the credential editor must not open over the key editor");
|
||||
vault.IsEditingKey.ShouldBeTrue();
|
||||
vault.Status.ShouldContain("SSH key");
|
||||
|
||||
@@ -1135,43 +1212,66 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
// And it is a refusal, not a lockout.
|
||||
vault.CancelKeyEditCommand.Execute(null);
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.NewCredentialCommand.Execute(null);
|
||||
vault.IsEditingCredential.ShouldBeTrue();
|
||||
|
||||
// Symmetrically, with the host editor holding the column.
|
||||
vault.EditorLabel = "half-typed";
|
||||
// Symmetrically, with the credential editor holding the screen.
|
||||
vault.CredentialEditorPassword = "half-typed";
|
||||
vault.NewKeyCommand.Execute(null);
|
||||
|
||||
vault.IsEditingKey.ShouldBeFalse();
|
||||
vault.EditorLabel.ShouldBe("half-typed");
|
||||
vault.Status.ShouldContain("host");
|
||||
vault.CredentialEditorPassword.ShouldBe("half-typed");
|
||||
vault.Status.ShouldContain("credential");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditingAnExistingItem_IsRefusedByTheOtherEditorToo()
|
||||
public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherVaultScreenEditorToo()
|
||||
{
|
||||
// The Edit commands are a second door into the same column, and guarding only the Add ones would
|
||||
// The Edit commands are a second door into the same screen, and guarding only the Add ones would
|
||||
// leave it wide open.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.SelectedKey = vault.Keys[0];
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
|
||||
vault.NewKeyCommand.Execute(null);
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
vault.EditSelectedCredentialCommand.Execute(null);
|
||||
vault.IsEditingCredential.ShouldBeFalse();
|
||||
|
||||
vault.CancelKeyEditCommand.Execute(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.NewCredentialCommand.Execute(null);
|
||||
vault.EditSelectedKeyCommand.Execute(null);
|
||||
vault.IsEditingKey.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The host editor's own version of the same rule: opening a second host editor over the first, or
|
||||
/// editing an existing host while adding one, is refused — this is the one case the split guard still
|
||||
/// has to cover, because both doors lead to the same single editor on the Hosts screen.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostEditorRefusesToOpenOverItself()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "half-typed";
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorLabel.ShouldBe("half-typed", "the second door must not discard the first editor's draft");
|
||||
vault.Status.ShouldContain("host");
|
||||
}
|
||||
|
||||
// ---- Binding a key to a host ----
|
||||
|
||||
[Fact]
|
||||
@@ -1704,8 +1804,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
named.Select(choice => choice.Qualifier).ShouldBe(["SSH key", "credential"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The credential editor guards the vault screen's own rail, exactly as the key editor does — but no
|
||||
/// longer the host editor, which is a different screen and has nothing to lose by the rail moving.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheCredentialEditorIsAlsoOneEditorAtATime()
|
||||
public async Task TheCredentialEditorGuardsTheVaultScreensRail()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -1713,17 +1817,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.NewCredentialCommand.Execute(null);
|
||||
vault.CredentialEditorPassword = "half-typed";
|
||||
|
||||
// The host editor opens freely: it is the Hosts screen's own business now.
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.IsEditing.ShouldBeFalse("the host editor must not open over the credential editor");
|
||||
vault.Status.ShouldContain("credential");
|
||||
vault.IsEditing.ShouldBeTrue("a different screen's editor is not this one's to refuse");
|
||||
vault.IsEditingCredential.ShouldBeTrue("and opening it must not have closed the credential editor");
|
||||
vault.CredentialEditorPassword.ShouldBe("half-typed");
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
||||
vault.ShowsCredentials.ShouldBeTrue("and the selector must not move away from it either");
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
vault.ShowsCredentials.ShouldBeTrue("the rail must not move away from an open vault-screen editor");
|
||||
|
||||
vault.CancelCredentialEditCommand.Execute(null);
|
||||
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
||||
vault.ShowsHosts.ShouldBeTrue();
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
vault.ShowsAll.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- Pinned host keys ----
|
||||
@@ -1838,11 +1943,71 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
|
||||
|
||||
vault.ShowsKnownHosts.ShouldBeTrue();
|
||||
vault.ShowsHosts.ShouldBeFalse();
|
||||
vault.ShowsAll.ShouldBeFalse();
|
||||
vault.ShowsKeys.ShouldBeFalse();
|
||||
vault.ShowsCredentials.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Filtering the host sidebar ----
|
||||
|
||||
/// <remarks>
|
||||
/// The filter's own list is a projection over <see cref="VaultViewModel.Hosts"/>, not the bound source
|
||||
/// of the sidebar's selection — but <c>HostSidebar</c>'s <c>ListBox</c> two-way binds
|
||||
/// <c>SelectedItem</c> to <see cref="VaultViewModel.SelectedHost"/> against exactly that projection, so
|
||||
/// a naive rebuild that cleared the list before refilling it would have the list null the selection out
|
||||
/// from under the user on every keystroke, even when the filter still matches the selected host.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task FilteringTheHostListPreservesTheSelectionWhenItStillMatches()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-web-01");
|
||||
await AddHostAsync(vault, "prod-web-02");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "prod-web-01");
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
|
||||
vault.VisibleHosts.Count.ShouldBe(2, "both hosts match the filter");
|
||||
vault.SelectedHost.ShouldNotBeNull();
|
||||
vault.SelectedHost!.Label.ShouldBe("prod-web-01", "a filter that still matches must not clear it");
|
||||
|
||||
vault.HostFilter = "web-02";
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem();
|
||||
vault.SelectedHost.ShouldBeNull("the selected host no longer matches, so there is nothing to keep");
|
||||
|
||||
vault.HostFilter = string.Empty;
|
||||
|
||||
vault.VisibleHosts.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The same hazard as the filter, reached through the other caller of the rebuild: a background sync
|
||||
/// pass reloads the host list every minute, and <c>ReloadHostsAsync</c> deliberately restores the
|
||||
/// selection before handing off to the sidebar's projection. Losing it there would be exactly the "move
|
||||
/// the terminal's target out from under the user" outcome that restoration exists to prevent.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ReloadingTheVaultPreservesTheSidebarsSelection()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "stage-web");
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedHost.ShouldNotBeNull();
|
||||
vault.SelectedHost!.Label.ShouldBe("stage-web");
|
||||
vault.VisibleHosts.ShouldContain(row => ReferenceEquals(row, vault.SelectedHost));
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
@@ -117,7 +117,95 @@ public sealed class TerminalWorkspaceTests
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Disposal is the one path that does close sessions, because it is process shutdown. Asserted so
|
||||
/// <para>
|
||||
/// Per-session liveness, which the tab strip's status dot is. The aggregate count answers "may I walk
|
||||
/// away"; this answers "is <em>this</em> tab still connected", and a tab that went on claiming a shell
|
||||
/// it no longer has would be the same lie one row down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The unknown-id case is asserted because it is what a stale tab asks. A session id this workspace
|
||||
/// never issued, or has already closed, is not live — it must not throw and must not be optimistic.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task LivenessIsAnsweredPerSession()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var first = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
var second = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
workspace.IsSessionLive(first).ShouldBeTrue();
|
||||
workspace.IsSessionLive(second).ShouldBeTrue();
|
||||
workspace.IsSessionLive(9999).ShouldBeFalse("this workspace never issued that id");
|
||||
|
||||
await workspace.CloseSessionAsync(first);
|
||||
|
||||
workspace.IsSessionLive(first).ShouldBeFalse();
|
||||
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
|
||||
/// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
|
||||
/// closed has a caller who already knows, and telling it would turn one close into two.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASessionEndingOnItsOwnIsAnnounced()
|
||||
{
|
||||
// A shell with no output to give: its first read returns 0, which is a remote closing the channel,
|
||||
// so the pump finishes with nobody asking it to.
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var ended = new List<uint>();
|
||||
workspace.SessionEnded += (_, e) =>
|
||||
{
|
||||
lock (ended)
|
||||
{
|
||||
ended.Add(e.SessionId);
|
||||
}
|
||||
};
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() =>
|
||||
{
|
||||
lock (ended)
|
||||
{
|
||||
return ended.Contains(sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ASessionEndingOnItsOwnIsAnnounced" />
|
||||
[Fact]
|
||||
public async Task ClosingASessionIsNotAnnouncedBack()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var announcements = 0;
|
||||
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.CloseSessionAsync(sessionId);
|
||||
|
||||
Volatile.Read(ref announcements)
|
||||
.ShouldBe(0, "a close the caller asked for is not news to report back to it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
|
||||
/// that the SSH connections are known to be released rather than assumed to be.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user