Public Access
2184 lines
92 KiB
C#
2184 lines
92 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Import;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Session.Tests;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Client.Transfer;
|
|
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!;
|
|
|
|
/// <remarks>
|
|
/// Constructed and never started: the sign-out card binds to the shell rather than to a vault, and what
|
|
/// it shows comes from properties a fresh one already answers. Starting it would migrate a cache and
|
|
/// read a profile, neither of which any rectangle here depends on.
|
|
/// </remarks>
|
|
private MainWindowViewModel shell = null!;
|
|
|
|
/// <remarks>
|
|
/// Over a substitute factory that is never asked for a session. Every shape measured here is one the
|
|
/// screen is in before a connection exists or after one has failed, which is deliberate: the two panes
|
|
/// are at their widest with the local one full and the remote one carrying its explanation, and a
|
|
/// connected pane is the same template with shorter names in it.
|
|
/// </remarks>
|
|
private TransfersViewModel transfers = 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);
|
|
|
|
shell = new MainWindowViewModel(
|
|
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-layout-{Guid.CreateVersion7():N}")),
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
new UnavailableDeviceKeyStore(),
|
|
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
|
TimeProvider.System,
|
|
Substitute.For<ISftpSessionFactory>(),
|
|
CheapProfile);
|
|
|
|
transfers = new TransfersViewModel(
|
|
Substitute.For<ISftpSessionFactory>(), TimeProvider.System);
|
|
|
|
await SeedAsync();
|
|
|
|
// Attached after seeding, so the host picker has something in it and the local pane has listed this
|
|
// machine's home directory — which is what puts real names of real length into the row template.
|
|
transfers.Attach(vault, knownHosts);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await transfers.DisposeAsync();
|
|
await vault.DisposeAsync();
|
|
knownHosts.Close();
|
|
await workspace.DisposeAsync();
|
|
await session.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
// ---- The hosts drawer ----
|
|
//
|
|
// This was the host sidebar's section. The control kept the half of that column that is about one host
|
|
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
|
|
// the right. v5 widened it again, to 320, for the ADDRESS field's own breathing room; see
|
|
// LayoutHarness.HostDrawerWidth.
|
|
|
|
/// <remarks>
|
|
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
|
|
/// when the pencil arrived: a selected host no longer puts the pane up, so a test that only selected one
|
|
/// would measure a drawer with all three panels collapsed and pass on an empty column. See
|
|
/// <c>VaultViewModel.IsHostPaneOpen</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostDrawerFitsShowingAHost()
|
|
{
|
|
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
|
vault.IsShowingHostDetail.ShouldBeTrue("there is nothing to measure otherwise");
|
|
|
|
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The tight one, and the reason this suite still exists. The host editor is the tallest thing the
|
|
/// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a
|
|
/// wrapped row of tag chips, a checkbox, a relay card, QUICK ACCESS's own rows and its add row, a
|
|
/// paragraph of hint text and three buttons.
|
|
///
|
|
/// A pin is staged so QUICK ACCESS draws at least one row rather than only its empty add box — the
|
|
/// harness skips the scrolled content's height (see the remark above
|
|
/// <see cref="TheHostDrawerFitsWithADeletionInQuestion"/>) but not its width, and a row's own mono path
|
|
/// is the one place in this card long text could push the column sideways if TextTrimming ever slipped.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
|
|
{
|
|
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);
|
|
|
|
vault.EditorNewPin = "/var/www/a-fairly-long-application-directory-name";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
|
|
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be
|
|
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
|
|
/// three pickers are the same width as the host editor's and its labels are longer.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostDrawerFitsWithTheGroupEditorOpen()
|
|
{
|
|
await SeedGroupsAsync(3);
|
|
|
|
vault.GroupFilter = vault.Groups[0];
|
|
vault.EditGroupCommand.Execute(null);
|
|
|
|
vault.IsEditingGroup.ShouldBeTrue("the desktop raises this now, as the phone always did");
|
|
|
|
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The question in place of the three buttons. Its tallest shape is a host with a terminal open on it,
|
|
/// which adds a disclosure the ordinary case has not got.
|
|
/// </para>
|
|
/// <para>
|
|
/// Still worth measuring although the drawer scrolls as a whole now — see <c>HostDrawer.axaml</c> — and
|
|
/// the reason has changed rather than gone. The harness skips anything inside a <c>ScrollViewer</c>, so
|
|
/// what this holds is not that the buttons are on screen but that the drawer itself does not blow its
|
|
/// column sideways. The question is the widest thing it draws: a sentence with a host name in it.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostDrawerFitsWithADeletionInQuestion()
|
|
{
|
|
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
|
vault.SelectedHost.ShouldNotBeNull().IsConnected = true;
|
|
|
|
vault.DeleteHostCommand.Execute(null);
|
|
vault.IsConfirmingDeletion.ShouldBeTrue();
|
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
|
|
|
|
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
|
|
/// heading, a combo box, a wrapping paragraph and two buttons, in a 320-pixel column. The paragraph is
|
|
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
|
|
/// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
|
|
/// </para>
|
|
/// <para>
|
|
/// The state is set here rather than through <c>MoveHostCommand</c>, which would refuse: this fixture's
|
|
/// account holds one vault, and the command declines rather than open a picker with nothing in it. What
|
|
/// this test is about is the rectangle, and the flow that fills it is covered in
|
|
/// <c>DodoSSH.Client.App.Tests</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostDrawerFitsWithTheMovePanelOpen()
|
|
{
|
|
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
|
|
|
vault.MoveVaultChoices.Add(
|
|
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
|
|
|
vault.SelectedMoveVault = vault.MoveVaultChoices[0];
|
|
vault.IsMovingHost = true;
|
|
|
|
vault.ShowsHostPaneActions.ShouldBeFalse("the panel takes the footer rather than sharing it");
|
|
|
|
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
|
|
/// The gesture is wired in the control rather than bound in the markup, which is exactly the sort of
|
|
/// wiring that compiles whether or not it is connected to anything — so it is worth a test that
|
|
/// performs the gesture.
|
|
/// </para>
|
|
/// <para>
|
|
/// Proved through a connection that is refused before any network is involved. The host is left bound
|
|
/// to a key that has been deleted, which <c>TryBuildAuthentication</c> turns into a sentence on the
|
|
/// status line rather than a socket — so what this asserts is that the command ran, with nothing
|
|
/// timing out to make it flaky.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DoubleClickingAHostConnectsToIt()
|
|
{
|
|
var keyId = vault.Keys[0].EntityId;
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
|
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedKey = vault.Keys.Single(row => row.EntityId == keyId);
|
|
vault.DeleteKeyCommand.Execute(null);
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = null;
|
|
vault.Status = string.Empty;
|
|
|
|
await OnTheHostsScreenAsync((screen, window) =>
|
|
{
|
|
var card = screen.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.First(item => item.DataContext is HostRowViewModel);
|
|
|
|
var centre = card.TranslatePoint(
|
|
new Point(card.Bounds.Width / 2, card.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the card is not in this window's tree");
|
|
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
vault.SelectedHost.ShouldNotBeNull("a press on a card selects it");
|
|
vault.Status.ShouldContain(
|
|
"not in this keychain any more",
|
|
Case.Insensitive,
|
|
"the double-click has to reach the connect command");
|
|
});
|
|
}
|
|
|
|
/// <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 an empty one has no item to take it either, which is the second shape below.
|
|
/// </para>
|
|
/// <para>
|
|
/// The empty shape used to be the sidebar's folded-away list and is now a filter that matches nothing.
|
|
/// That is a state a user reaches far more often than the old one: it is one keystroke away from every
|
|
/// search.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreensKeyboardTargetTakesFocusInBothOfItsShapes()
|
|
{
|
|
await OnTheHostsScreenAsync((screen, _) =>
|
|
{
|
|
screen.KeyboardTarget.ShouldBeSameAs(screen.Board);
|
|
screen.KeyboardTarget.Focus().ShouldBeTrue("the board has cards on it");
|
|
});
|
|
|
|
vault.HostFilter = "nothing matches this";
|
|
vault.HasHostBoardEntries.ShouldBeFalse();
|
|
|
|
await OnTheHostsScreenAsync((screen, _) =>
|
|
{
|
|
screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter);
|
|
screen.KeyboardTarget.Focus().ShouldBeTrue("the board is empty, so the find box takes it");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The editor open with the board still on screen beside it, which is the state a user is most likely to
|
|
/// leave this screen in — so it is the state the keyboard answer most has to hold in.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreensKeyboardTargetStillTakesFocusWithTheEditorOpen()
|
|
{
|
|
vault.NewHostCommand.Execute(null);
|
|
|
|
await OnTheHostsScreenAsync((screen, _) =>
|
|
{
|
|
screen.Board.IsEffectivelyVisible.ShouldBeTrue();
|
|
screen.KeyboardTarget.Focus().ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// v5: each section's own card list holds cards and nothing else — the heading is a sibling, not a row.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This used to hold for one flat grid: no group headings were mixed into the host rows, because the
|
|
/// group cards above it were where a group's name was drawn. The board replaces that grid with one
|
|
/// section per group — see <c>VaultViewModel.HostSections</c> — and the invariant moves down a level
|
|
/// with it: it is each section's own <c>ListBox</c> (<c>Classes="sectioncards"</c>) that must hold only
|
|
/// <see cref="HostRowViewModel"/>s, because <c>HostsScreen.axaml.cs</c> finds every card by walking every
|
|
/// list wearing that class and would misfire on a heading it found mixed in.
|
|
/// </para>
|
|
/// <para>
|
|
/// Measured with every host filed under one group, which is the shape that puts the most rows behind one
|
|
/// heading.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EachSectionsCardListHoldsCardsAndNothingElse()
|
|
{
|
|
await SeedGroupsAsync(3);
|
|
|
|
// Filed through the host editor, same as SeedGroupsAsync itself does — the drag this used to exercise
|
|
// left with the group cards in v5; see VaultViewModel.EditGroup's own remarks.
|
|
foreach (var host in vault.Hosts.ToArray())
|
|
{
|
|
vault.SelectedHost = host;
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorSelectedGroup = vault.EditorGroupChoices
|
|
.First(choice => choice.EntityId == vault.Groups[0].EntityId);
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
await OnTheHostsScreenAsync((screen, _) =>
|
|
{
|
|
var rows = screen.GetVisualDescendants()
|
|
.OfType<ListBox>()
|
|
.Where(list => list.Classes.Contains("sectioncards"))
|
|
.SelectMany(list => list.GetVisualDescendants().OfType<ListBoxItem>())
|
|
.Select(item => item.DataContext)
|
|
.ToList();
|
|
|
|
rows.ShouldNotBeEmpty("the seed has to put hosts in one of the sections");
|
|
rows.ShouldAllBe(row => row is HostRowViewModel);
|
|
});
|
|
|
|
await MeasureHostsAsync(
|
|
faults => faults.ShouldBeEmpty("with every host filed under one group and four sections drawn"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The narrowest the grid ever gets, and the width the tile was sized against: the window at its
|
|
/// minimum, less the nav rail and less the drawer.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithTheDrawerOpen()
|
|
{
|
|
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
|
vault.IsDrawerOpen.ShouldBeTrue();
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The grid is still a grid at the window's minimum with the drawer open.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The harness cannot see this and never will.</b> Its one rule is that a control is inside the
|
|
/// window, so a wrap that has quietly collapsed to a single column reports perfectly clean — every card
|
|
/// is inside, just one above the other. That is exactly what happened: the tile's width was set from
|
|
/// arithmetic that left out the scrolling stack's own margins, and the grid became a list with extra
|
|
/// padding at precisely the size this application guarantees.
|
|
/// </para>
|
|
/// <para>
|
|
/// Two per row rather than a width assertion, because the number that matters is the number of columns.
|
|
/// A width is one of the inputs — the margins, the padding and the scrollbar are the others — and
|
|
/// pinning the input would go on passing while any of the rest moved.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen()
|
|
{
|
|
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
|
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
|
|
|
|
await OnTheHostsScreenAsync((screen, window) =>
|
|
{
|
|
var cards = screen.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.Where(item => item.DataContext is HostRowViewModel)
|
|
.Select(item => item.TranslatePoint(default, window)
|
|
?? throw new InvalidOperationException("a card is not in this window's tree"))
|
|
.ToList();
|
|
|
|
cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host on the board");
|
|
|
|
cards.GroupBy(point => Math.Round(point.Y))
|
|
.Max(row => row.Count())
|
|
.ShouldBeGreaterThanOrEqualTo(
|
|
2,
|
|
"at the window's minimum, with the drawer out, the cards still wrap two to a row");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The widest the drawer's own contents get while the grid is beside them: the host editor open, which
|
|
/// is what EDIT does to a screen that already has both columns up.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithTheDrawerEditingAHost()
|
|
{
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the editor out beside the grid"));
|
|
}
|
|
|
|
// ---- The hosts screen ----
|
|
//
|
|
// Measurable for the first time. Every rectangle below lived in MainWindow.axaml until the terminal
|
|
// moved out from under it, and nothing in that window can be laid out here — so the connect banner, the
|
|
// two host key prompts and the conflict log had never been through this harness at all. They are also
|
|
// the four worst candidates for that: each appears only in a state somebody has to reproduce by hand.
|
|
//
|
|
// The host key prompts have since left this screen for a card over the whole surface, and their two tests
|
|
// went with them; see the host key decision below.
|
|
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithNothingToAnnounce()
|
|
{
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("the ordinary shape"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithAHostSelected()
|
|
{
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the overview showing a host"));
|
|
}
|
|
|
|
// ---- The host key decision ----
|
|
//
|
|
// ◆ These two were TheHostsScreenFits… tests, because the prompts were banners at the top of that screen
|
|
// and the shell navigated there before either could be raised. They are a card over the whole surface
|
|
// now — see HostKeyCard.axaml — so they are measured in the rectangle a card gets rather than in a
|
|
// screen's rows, and they moved rather than being rewritten: the shapes worth measuring are the same two,
|
|
// and each still only appears in a state somebody has to reproduce by hand.
|
|
|
|
/// <remarks>
|
|
/// A full fingerprint is the widest line here and it must not be trimmed — the whole point of the card is
|
|
/// that somebody can compare it character by character against what an operator published, and an
|
|
/// ellipsis in the middle of one is worse than a card that does not fit, because it looks correct.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostKeyCardFitsWhileAKeyIsBeingApproved()
|
|
{
|
|
vault.PendingHostKey = new HostKeyPresentation(
|
|
"db.internal", 22, "ssh-ed25519", "SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI");
|
|
|
|
await MeasureHostKeyAsync(faults => faults.ShouldBeEmpty("with the unknown-key decision up"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The taller of the two, and the one whose height is not this control's to choose: the explanation is
|
|
/// composed by the view model out of a host, a port and two full fingerprints, so it wraps to several
|
|
/// lines and grows with the length of a hostname.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostKeyCardFitsWhileAKeyIsRefused()
|
|
{
|
|
vault.HostKeyMismatch =
|
|
"The host key for db.production.internal:22 has changed. "
|
|
+ "Pinned SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI, but the server offered "
|
|
+ "SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0.";
|
|
|
|
await MeasureHostKeyAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Twenty, because one is not the case that broke. The log sits on an <c>Auto</c> row above the overview,
|
|
/// and an <c>ItemsControl</c> with no ceiling grows for as long as it has rows — so a pass that merged a
|
|
/// vault's worth of items pushed everything below it off the bottom of a screen with nothing to scroll.
|
|
/// It survived as long as it did because this markup was inside the window, where no test could reach it;
|
|
/// finding it is what the extraction was for. The fix is the <c>ScrollViewer</c> and <c>MaxHeight</c> in
|
|
/// <c>HostsScreen.axaml</c>, and this is what holds them there.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithAConflictLogTooLongToShow()
|
|
{
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
vault.Conflicts.Add(new ConflictRowViewModel(new ConflictNotice(
|
|
Guid.CreateVersion7(),
|
|
Guid.CreateVersion7(),
|
|
ConflictKind.FieldOverridden,
|
|
$"'host-{i}' was changed on two machines, and the other machine's value was kept.",
|
|
[],
|
|
TimeProvider.System.GetUtcNow())));
|
|
}
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with twenty merged conflicts to report"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The group cards are a wrap above the host cards, so more of them than a row holds is the case that
|
|
/// pushes the hosts down rather than one that overflows sideways. Six, because that is more than
|
|
/// anybody's first three and enough to need a second row at the window's minimum.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithMoreGroupsThanARowHasRoomFor()
|
|
{
|
|
await SeedGroupsAsync(6);
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six group cards above the hosts"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The question opens under the GROUPS heading and pushes the cards down, and it is the tallest thing
|
|
/// this section draws: a heading, a consequence, a boxed count, and now a tick with a sentence beside it
|
|
/// asking whether the machines go too. The tick is the part worth measuring, because it is a wrapping
|
|
/// paragraph inside a control whose own height the layout does not obviously account for.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
|
|
{
|
|
await SeedGroupsAsync(3);
|
|
|
|
// Passed directly, the way DeleteGroupFromHeading resolves and hands in a row now that there is no
|
|
// group-card selection to fall back on.
|
|
vault.DeleteGroupCommand.Execute(vault.Groups[0]);
|
|
|
|
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
|
vault.PendingDeletion.ShouldNotBeNull().HasChoice
|
|
.ShouldBeTrue("the hosts filed under it are what makes this the long shape");
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The group's move panel, under the GROUPS heading beside the deletion question and the wordier of the
|
|
/// two: a heading, a combo box, a wrapping paragraph naming everything that travels and everything that
|
|
/// does not, and two buttons — above a wrap of group cards and the host grid, all of which still have to
|
|
/// fit under it.
|
|
/// </para>
|
|
/// <para>
|
|
/// The state is set here rather than through <c>MoveGroupCommand</c>, which would refuse: this fixture's
|
|
/// account holds one vault, and the command declines rather than open a picker with nothing in it. The
|
|
/// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithTheGroupMovePanelOpen()
|
|
{
|
|
await SeedGroupsAsync(3);
|
|
|
|
vault.MoveGroupVaultChoices.Add(
|
|
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
|
|
|
vault.SelectedMoveGroupVault = vault.MoveGroupVaultChoices[0];
|
|
vault.IsMovingGroup = true;
|
|
|
|
vault.IsConfirmingGroupDeletion.ShouldBeFalse("the two panels share the space and never the moment");
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
|
|
}
|
|
|
|
// ---- ◆ The hosts screen with a set of cards ticked ----
|
|
//
|
|
// Ctrl, Shift and a band put the phone's chosen-hosts set on this screen — see HostsScreen.axaml.cs — and
|
|
// with it come one strip and three panels that had never been drawn in a window. All four sit between the
|
|
// HOSTS heading and the grid, so every one of them shortens the grid rather than overflowing it; that is
|
|
// the property these measure. The gestures themselves are HostGridTests'.
|
|
|
|
/// <remarks>
|
|
/// The strip: a count, CLEAR, and the sentence saying the actions are on the menu — squeezed between the
|
|
/// heading and the grid's own count on the same row. It is the one thing on this screen whose width is
|
|
/// set by nothing but its text, so what is really being measured is that the sentence trims instead of
|
|
/// running out over the number at the far end. Measured with the drawer open, which is the width at which
|
|
/// it does not fit and has to.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWhileCardsAreTicked()
|
|
{
|
|
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
|
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
|
|
|
vault.IsChoosingHosts.ShouldBeTrue("the strip is only drawn while something is ticked");
|
|
|
|
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
|
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with two cards ticked"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The tallest of the three panels: the vault picker with the key question under it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A heading, a picker, a wrapping paragraph naming everything that travels and everything that does not,
|
|
/// a tick with a second wrapping sentence beside it, and two buttons — all above the group cards and the
|
|
/// grid, which still have to fit under it.
|
|
/// </para>
|
|
/// <para>
|
|
/// The host is given a key first, because the tick is drawn only for a move of exactly one host that has
|
|
/// something to bring; without that this would measure the short shape and say the long one fits. The
|
|
/// panel is opened by hand rather than through <c>MoveChosenHostsToVault</c> for the reason the group
|
|
/// move test gives: this fixture's account holds one vault, and the command declines rather than open a
|
|
/// picker with nothing in it.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithTheChosenHostsVaultPanelOpen()
|
|
{
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
|
.First(choice => choice.Kind is AuthenticationKind.SshKey);
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
|
|
|
vault.ChosenHostVaultChoices.Add(
|
|
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
|
|
|
vault.SelectedChosenHostVault = vault.ChosenHostVaultChoices[0];
|
|
vault.IsSendingChosenHostsToAVault = true;
|
|
|
|
vault.HasAChosenBindingToBring
|
|
.ShouldBeTrue("the key question is the part of this panel worth measuring");
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's vault panel up"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The group picker over the set, which is the same panel the phone draws and the same write a set
|
|
/// dragged onto a group card makes. Shorter than the vault panel above and drawn in the same place, so
|
|
/// what this adds is the picker being filled from one keychain's groups rather than from nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWithTheChosenHostsGroupPanelOpen()
|
|
{
|
|
await SeedGroupsAsync(3);
|
|
|
|
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
|
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
|
|
|
vault.RegroupChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
|
|
vault.ChosenHostGroupChoices.Count.ShouldBeGreaterThan(1, "no group, and the three seeded ones");
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's group panel up"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One question for the whole set, drawn by the same card the group deletion above uses. The count is
|
|
/// what makes it a confirmation somebody reads rather than one they press past, and the consequence line
|
|
/// wraps — which is the part a narrower column would push out of the window.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostsScreenFitsWhileTheChosenHostsAreBeingDeleted()
|
|
{
|
|
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
|
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
|
|
|
vault.DeleteChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
|
|
|
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's deletion question up"));
|
|
}
|
|
|
|
// ---- The vault screen ----
|
|
|
|
[Fact]
|
|
public async Task TheKeychainScreenFitsInEveryCategory()
|
|
{
|
|
foreach (var section in new[]
|
|
{
|
|
VaultSection.All, VaultSection.Keys, VaultSection.Credentials,
|
|
VaultSection.Tags, VaultSection.Buckets,
|
|
})
|
|
{
|
|
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 TheKeychainScreenFitsWithTheKeyEditorOpen()
|
|
{
|
|
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 TheKeychainScreenFitsWithThePasswordEditorOpen()
|
|
{
|
|
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 generate form, in the 244-pixel detail pane — two algorithm buttons side by side plus two
|
|
/// paragraphs of explanation, in the narrowest column in the application. The paragraphs are the risk:
|
|
/// they are what says the file has no passphrase, and a sentence pushed off the bottom is a limitation
|
|
/// nobody was told about.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheKeychainScreenFitsWithTheGenerateFormOpen()
|
|
{
|
|
vault.NewGeneratedKeyCommand.Execute(null);
|
|
vault.IsGeneratingKey.ShouldBeTrue();
|
|
|
|
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The S3 screen before there is a bucket to open, which is the state every new account starts in and
|
|
/// the state whose contents changed: a heading, a paragraph and a button where an empty picker used to
|
|
/// be. The paragraph is the risk — it is what says a bucket is a keychain item — and it sits in the
|
|
/// 320-pixel invitation column with no scroll viewer above it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheS3ScreenFitsWithNoBucketsToOpen()
|
|
{
|
|
transfers.Remote = RemoteKind.Bucket;
|
|
|
|
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
|
|
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Both drop highlights forced on at once, which is a state the screen never actually reaches — the
|
|
/// point is that an overlay covering a whole pane does not change the layout of anything beneath it.
|
|
/// It cannot check the thing most likely to be wrong, which is <c>IsHitTestVisible="False"</c>: an
|
|
/// overlay that hit-tests lays out identically and swallows the events that would clear it. That one is
|
|
/// in docs/manual-checks.md.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreenFitsWithTheDropHighlightsShowing()
|
|
{
|
|
transfers.IsLocalDropTarget = true;
|
|
transfers.IsRemoteDropRefused = true;
|
|
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a drop in progress"));
|
|
}
|
|
|
|
// ---- The import screen ----
|
|
|
|
[Fact]
|
|
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
|
|
{
|
|
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
|
|
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
|
|
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
|
|
/// understands and one they think is broken.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
|
|
{
|
|
await MeasureImportAsync(
|
|
faults => faults.ShouldBeEmpty("with a scanned list"),
|
|
await ScannedImportAsync());
|
|
}
|
|
|
|
// ---- The host keys screen ----
|
|
|
|
[Fact]
|
|
public async Task TheHostKeysScreenFitsWithNothingApprovedYet()
|
|
{
|
|
foreach (var pin in vault.KnownHostPins.ToList())
|
|
{
|
|
await knownHosts.ForgetAsync(pin.Host, pin.Port, Token);
|
|
}
|
|
|
|
await vault.LoadAsync(Token);
|
|
vault.KnownHostPins.ShouldBeEmpty();
|
|
|
|
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("the empty state"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The shape the column widths were chosen for. A fingerprint is never trimmed — comparing a shortened
|
|
/// one against a published one is not something anybody can do — so this table has one column that
|
|
/// refuses to give ground, and this is what says the rest still fits beside it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostKeysScreenFitsWithPinsAndOneSelected()
|
|
{
|
|
var pins = new KnownHostsViewModel(vault);
|
|
pins.VisiblePins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
|
|
pins.Selected = pins.VisiblePins[0];
|
|
|
|
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with a pin selected"), pins);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheHostKeysScreenFitsWhenTheFilterMatchesNothing()
|
|
{
|
|
var pins = new KnownHostsViewModel(vault) { Filter = "no such fingerprint" };
|
|
pins.VisiblePins.ShouldBeEmpty();
|
|
|
|
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), pins);
|
|
}
|
|
|
|
// ---- The connecting card ----
|
|
//
|
|
// It fills the terminal's own rectangle, which is the one part of this window no other test can lay out:
|
|
// the WebView it stands in for cannot be attached here at all. That makes it worth measuring for exactly
|
|
// the reason the harness exists — its two buttons are the only way out of a connection that is not
|
|
// going to happen.
|
|
|
|
[Fact]
|
|
public async Task TheConnectingCardFitsWhileAConnectionIsBeingMade()
|
|
{
|
|
await MeasureConnectingAsync(
|
|
faults => faults.ShouldBeEmpty("while connecting"),
|
|
new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The taller of the two shapes, and the one with something variable in it: a refusal is whatever the
|
|
/// SSH layer said, which is a sentence rather than a word.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheConnectingCardFitsWithARefusalInIt()
|
|
{
|
|
var tab = new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22");
|
|
|
|
tab.Failed(
|
|
"Permission denied (publickey,keyboard-interactive). The server closed the connection after "
|
|
+ "three attempts.");
|
|
|
|
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
|
|
}
|
|
|
|
// ---- The logs screen ----
|
|
|
|
[Fact]
|
|
public async Task TheLogsScreenFitsWithNeitherLogWrittenTo()
|
|
{
|
|
await MeasureLogsAsync(faults => faults.ShouldBeEmpty("the empty state"), LogSection.Connections);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Six columns in one row, and the two widest — an address and a device name — are both variable. A
|
|
/// connection still open is measured alongside the finished ones because its row carries the longest
|
|
/// value the LASTED column ever holds: the words "still open" rather than a duration.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheConnectionLogFitsWithALiveRowAndAFinishedOne()
|
|
{
|
|
var logs = await SeedLogsAsync();
|
|
|
|
logs.Connections.ShouldNotBeEmpty();
|
|
logs.Connections.Any(row => row.IsLive).ShouldBeTrue("the live row is the wide one");
|
|
|
|
await MeasureLogsAsync(
|
|
faults => faults.ShouldBeEmpty("with a live connection above a finished one"),
|
|
LogSection.Connections,
|
|
logs);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The FIELDS column is the one that grows: it is a list of names, and a host has thirteen of them —
|
|
/// eleven until inheritance added "Password prompt" and tags added "Tags". Measured with an edit that
|
|
/// touched several, because one field name fits anywhere. The count is stated rather than derived, so
|
|
/// it has to be recounted against <c>HostKind.Changes</c> whenever a field is added; the literal in
|
|
/// <c>SeedLogsAsync</c> is the thing that actually keeps the column measured at its worst.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheActivityLogFitsWithAnEditThatTouchedSeveralFields()
|
|
{
|
|
var logs = await SeedLogsAsync();
|
|
|
|
logs.Section = LogSection.Activity;
|
|
logs.Activity.ShouldNotBeEmpty();
|
|
|
|
await MeasureLogsAsync(
|
|
faults => faults.ShouldBeEmpty("with the keychain log showing"), LogSection.Activity, logs);
|
|
}
|
|
|
|
// ---- The snippets screen ----
|
|
|
|
[Fact]
|
|
public async Task TheSnippetsScreenFitsWithNothingSavedYet()
|
|
{
|
|
vault.Snippets.ShouldBeEmpty("the seed makes none, which is what a new keychain looks like");
|
|
|
|
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("the empty state"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The detail pane's longest shape: a multi-line command in a box, its notes, two buttons and the
|
|
/// paragraph saying what a terminal will do with it — in a 300-pixel column. Measured with a snippet
|
|
/// that runs, because that is the one with the extra button.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheSnippetsScreenFitsWithAMultiLineSnippetSelected()
|
|
{
|
|
await SeedSnippetsAsync();
|
|
|
|
var snippets = NewSnippetsScreen(new InsertTarget(1, "prod-db"));
|
|
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
|
|
|
|
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with a running snippet selected"), snippets);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The editor, which is the tallest thing on this screen: a name, a 140-pixel command box, notes, the
|
|
/// checkbox and the paragraph explaining what leaving it off buys.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheSnippetsScreenFitsWithItsEditorOpen()
|
|
{
|
|
await SeedSnippetsAsync();
|
|
|
|
var snippets = NewSnippetsScreen();
|
|
snippets.Selected = snippets.Visible[0];
|
|
snippets.EditCommand.Execute(null);
|
|
|
|
snippets.IsEditing.ShouldBeTrue();
|
|
|
|
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The move panel, which is how a snippet gets shared and takes the insert controls' place while it is
|
|
/// up: a heading, a combo box, a wrapping paragraph and two buttons, in the same 300-pixel column the
|
|
/// detail pane has. The paragraph is the risk — it is what says who can read the command afterwards.
|
|
/// </para>
|
|
/// <para>
|
|
/// The state is set here rather than through <c>MoveCommand</c>, which would refuse: this fixture's
|
|
/// account holds one vault, and the command declines rather than open a picker with nothing in it. The
|
|
/// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>. The same arrangement, and the same
|
|
/// reason, as <see cref="TheHostDrawerFitsWithTheMovePanelOpen"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheSnippetsScreenFitsWithTheMovePanelOpen()
|
|
{
|
|
await SeedSnippetsAsync();
|
|
|
|
var snippets = NewSnippetsScreen();
|
|
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
|
|
|
|
snippets.MoveVaultChoices.Add(
|
|
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
|
|
|
snippets.SelectedMoveVault = snippets.MoveVaultChoices[0];
|
|
snippets.IsMoving = true;
|
|
|
|
snippets.ShowsSelectionActions.ShouldBeFalse("the panel takes the pane rather than sharing it");
|
|
|
|
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the move panel open"), snippets);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
|
|
{
|
|
await SeedSnippetsAsync();
|
|
|
|
var snippets = NewSnippetsScreen();
|
|
snippets.Filter = "no such command";
|
|
snippets.Visible.ShouldBeEmpty();
|
|
|
|
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), snippets);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
|
|
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
|
|
/// application.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheKeychainScreenFitsWithADeletionInQuestion()
|
|
{
|
|
var keyId = vault.Keys[0].EntityId;
|
|
|
|
foreach (var host in vault.Hosts.Take(4).ToList())
|
|
{
|
|
vault.SelectedHost = host;
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
|
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
vault.Section = VaultSection.Keys;
|
|
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == keyId);
|
|
|
|
vault.DeleteSelectedItemCommand.Execute(null);
|
|
|
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage
|
|
.ShouldBeTrue("four bound hosts are what makes this the long shape");
|
|
|
|
await OnTheVaultAsync((screen, window) =>
|
|
{
|
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
|
|
|
// And it says something. A card whose bindings did not resolve would lay out perfectly as three
|
|
// empty rows, which is the one failure a fit test cannot see: compiled bindings against the
|
|
// wrong data type are a logged message rather than an exception.
|
|
var card = screen.GetVisualDescendants().OfType<ConfirmDeleteCard>().ShouldHaveSingleItem();
|
|
|
|
var said = string.Join(
|
|
" ",
|
|
card.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text));
|
|
|
|
said.ShouldContain("key-0", Case.Insensitive, "the question has to name what is going");
|
|
said.ShouldContain("4 hosts authenticate with it");
|
|
said.ShouldContain("no undo");
|
|
});
|
|
}
|
|
|
|
/// <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(5, "one per category that exists");
|
|
|
|
foreach (var button in buttons)
|
|
{
|
|
button.Bounds.Height.ShouldBeGreaterThan(20);
|
|
button.Bounds.Width.ShouldBeGreaterThan(120);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- The transfers screen ----
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The widest thing in this window and the one with the least room to give: two file listings side by
|
|
/// side, each with four columns, and a queue underneath — all inside 826 pixels once the nav rail has
|
|
/// taken its column. The header row is the tight part, because it holds a host picker, a password box,
|
|
/// a button and a chip on one line.
|
|
/// </para>
|
|
/// <para>
|
|
/// Measured disconnected, which is the state the screen opens in and the one where the local pane is at
|
|
/// its fullest: it lists this machine's home directory, so the row template is exercised with real names
|
|
/// of real length rather than with fixtures chosen to fit.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreenFitsBeforeAnythingIsConnected()
|
|
{
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The queue is the half of this screen that only exists once something has been asked for, so a shape
|
|
/// nothing puts a row into is a shape never laid out. Three rows, because the row template changes with
|
|
/// the state: a running one shows a bar and a STOP, a stopped one shows RESUME and DISCARD, and a failed
|
|
/// one carries the server's own sentence in the column the other two put a byte count in.
|
|
/// </para>
|
|
/// <para>
|
|
/// The rows are placed directly rather than driven through the queue. What is being measured is the
|
|
/// template at each state, and running a real transfer to reach those states would put a thread-pool
|
|
/// hand-off and a filesystem in the middle of a test about rectangles. What the queue does is measured in
|
|
/// <c>DodoSSH.Client.Transfer.Tests</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreenFitsWithTransfersInTheQueue()
|
|
{
|
|
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
|
|
TransferState.Running, bytesPerSecond: 6_500_000);
|
|
|
|
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
|
|
TransferState.Cancelled);
|
|
|
|
Enqueue(TransferDirection.Upload, "deploy.sh", 4_096, 0, TransferState.Failed,
|
|
failure: "deploy.sh is already in that directory on the host. Rename or remove it first — "
|
|
+ "nothing here overwrites a file that is already there.");
|
|
|
|
transfers.Transfers.Count.ShouldBe(3);
|
|
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ The tallest the invitation gets, and the shape the connect bar's removal has to survive. Everything
|
|
/// that used to be a strip across the top of the screen is now stacked in the right-hand pane — a
|
|
/// picker, a password box and two buttons, under a heading — and that pane is only as tall as whatever
|
|
/// the queue leaves it.
|
|
/// </para>
|
|
/// <para>
|
|
/// So the queue is filled first, which is what takes it to its 196-pixel maximum and leaves the panes
|
|
/// their least. Without those rows this measures the invitation with nearly twice the room it is
|
|
/// guaranteed, which is the version of this test that would pass whatever was added to the panel.
|
|
/// </para>
|
|
/// <para>
|
|
/// No host is selected on purpose: a picker with nothing chosen shows the password box, because a host
|
|
/// that names neither a key nor a credential is one that will ask for a password. That is the shape with
|
|
/// the extra row in it.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreenFitsWithTheHostPickerOpenOverAFullQueue()
|
|
{
|
|
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
|
|
TransferState.Running, bytesPerSecond: 6_500_000);
|
|
|
|
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
|
|
TransferState.Cancelled);
|
|
|
|
Enqueue(TransferDirection.Upload, "deploy.sh", 4_096, 0, TransferState.Failed);
|
|
|
|
transfers.BeginChoosingRemoteCommand.Execute(null);
|
|
|
|
transfers.IsChoosingRemote.ShouldBeTrue();
|
|
transfers.SelectedHostAsksForAPassword
|
|
.ShouldBeTrue("the password box is what makes this the tall shape");
|
|
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with the picker open"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The bucket half of the picker, which differs by one row: no password box, because an object store
|
|
/// carries its keys in the vault and has nothing to type. Measured because the two are separate markup
|
|
/// rather than one picker with its items swapped — see the note on <c>RemoteKind</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreenFitsWithTheBucketPickerOpen()
|
|
{
|
|
transfers.Remote = RemoteKind.Bucket;
|
|
transfers.BeginChoosingRemoteCommand.Execute(null);
|
|
|
|
transfers.ShowsBucketPicker.ShouldBeTrue();
|
|
transfers.SelectedHostAsksForAPassword.ShouldBeFalse("a bucket never asks for one");
|
|
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with the bucket picker open"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The state the screen is in once something is open, and the reason it is worth a test of its own is
|
|
/// the strip the connect bar left behind: a chip naming the account and the endpoint, the status line,
|
|
/// and DISCONNECT — inside a pane that is under 400 pixels wide at the window's minimum, above a row
|
|
/// that already carries UP, REFRESH and DELETE.
|
|
/// </para>
|
|
/// <para>
|
|
/// The address is a long one deliberately. It is the part of that row with no fixed width, and a chip
|
|
/// that grew to fit whatever it was given is how the button beside it goes off the edge.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreenFitsWithASessionOpen()
|
|
{
|
|
transfers.IsConnected = true;
|
|
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
|
|
transfers.Status = "Connected to releases-eu.";
|
|
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a session open"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The trust card covers the whole screen, and it is the one thing here a user cannot get past without
|
|
/// pressing something — so a button of its own that fell outside the window would leave the screen
|
|
/// permanently blocked.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreenFitsWithTheHostKeyCardShowing()
|
|
{
|
|
transfers.PendingHostKey = new HostKeyPresentation(
|
|
"db.internal", 22, "ssh-ed25519", "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDEFG");
|
|
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
|
|
/// column while the listing under it is still showing. A directory, because that is the longer of the
|
|
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
|
|
{
|
|
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
|
"2026-07-30",
|
|
"/srv/releases/site/backups/nightly/2026-07-30",
|
|
IsDirectory: true);
|
|
|
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
|
}
|
|
|
|
// ---- 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>
|
|
/// <para>
|
|
/// The rail runs vertically, so what runs out here is height rather than width — at the window's minimum
|
|
/// the entries have to leave room for each other, which is the same failure the old four-button selector
|
|
/// was one label away from. It got tighter when the host keys left the keychain screen and became a
|
|
/// destination of their own, and tighter again with snippets and then the logs, which is why the count
|
|
/// is asserted rather than left to the fit check: an entry silently dropping off the bottom would still
|
|
/// pass every other assertion here.
|
|
/// </para>
|
|
/// <para>
|
|
/// Seven now, and it went down rather than up for the first time: SFTP and S3 became fixed tabs in the
|
|
/// strip, which is where a destination you stay in belongs. The number is asserted in both directions
|
|
/// for the same reason — an entry that reappeared here would be a route out of the tab the rail lives
|
|
/// in. See <c>NavRail.axaml</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheNavRailHoldsSevenDestinationsAtTheWindowsMinimum()
|
|
{
|
|
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(7, "one per screen the rail reaches, and SFTP and S3 are tabs");
|
|
|
|
foreach (var button in buttons)
|
|
{
|
|
button.Bounds.Height.ShouldBeGreaterThan(20);
|
|
|
|
// 190 wide, less the divider down the rail's right edge, less the 8 of inset on
|
|
// each side that v2 gives the rows so a filled one reads as a rounded row rather
|
|
// than as a full-width band. Stated exactly rather than as a lower bound: a button
|
|
// that stopped filling the row would leave a dead strip beside a destination, which
|
|
// is precisely the kind of near-miss a bound hides.
|
|
button.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 16);
|
|
}
|
|
|
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
|
|
// ---- The unlock screen ----
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The card a locked application is entirely made of, in its two shapes: an ordinary launch, and one
|
|
/// where shells were left running and the disclosure about them appears. It was extracted from
|
|
/// <c>MainWindow.axaml</c> to be measurable at all — that window cannot be shown here, so anything
|
|
/// inside it is unmeasured by construction — and it is the card with the least room to spare.
|
|
/// </para>
|
|
/// <para>
|
|
/// The status line is set to something long on purpose. It is bound to whatever the last thing that
|
|
/// happened said, and the longest of those is a sentence about an expired sign-in, which is exactly the
|
|
/// message this screen is most likely to be carrying on the launch where the extra rows also appear.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(2)]
|
|
public async Task TheUnlockCardFitsTheCardItIsShownIn(int liveSessions)
|
|
{
|
|
shell.LiveSessionCount = liveSessions;
|
|
shell.CanUnlockWithDevice = true;
|
|
shell.StatusMessage = "Your sign-in has expired, so this machine is offline: the token endpoint "
|
|
+ "returned 400: Invalid refresh token. Sign in again from Preferences to start syncing.";
|
|
|
|
await MeasureCardAsync(static () => new UnlockCard());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheUnlockBoxTakesEnterAsUnlock()
|
|
{
|
|
// Enter is how everybody finishes typing a password, and this screen had no answer to it until the
|
|
// gesture below existed: the passphrase box is where locking puts the keyboard, so the one thing a
|
|
// user does without thinking did nothing at all until they found the button.
|
|
//
|
|
// The gesture is what can be asserted; that pressing it unlocks is ShellFlowTests' business,
|
|
// against the command this binds to.
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var card = new UnlockCard { DataContext = shell };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);
|
|
|
|
try
|
|
{
|
|
var binding = card.PassphraseBox.KeyBindings.ShouldHaveSingleItem();
|
|
|
|
binding.Gesture.ShouldBe(new KeyGesture(Key.Enter));
|
|
binding.Command.ShouldBeSameAs(shell.UnlockCommand);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
|
|
// ---- The sign-out confirmation ----
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one new card that has to share a screen with an unlock prompt, and the only one whose height
|
|
/// depends on what it is saying: the warning is a sentence about the outbox, and the disclosure about
|
|
/// shells left running appears only when there are some. Both are wrapped paragraphs, which is the
|
|
/// shape that grows.
|
|
/// </para>
|
|
/// <para>
|
|
/// Measured in the space a card gives its contents rather than inside <c>MainWindow</c>, which cannot
|
|
/// be laid out here — see <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c>. What that leaves
|
|
/// unchecked is the card's own frame, which is a fixed border and a constant padding.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheSignOutCardFitsTheCardItIsShownIn()
|
|
{
|
|
// Its tallest shape: a shell left running adds a disclosure box that an ordinary sign-out does not
|
|
// have, an open transfer session adds a line beneath it, and a locked vault carries the longer of
|
|
// the two warnings.
|
|
shell.LiveSessionCount = 1;
|
|
shell.Transfers.IsConnected = true;
|
|
|
|
await MeasureCardAsync(static () => new SignOutCard());
|
|
}
|
|
|
|
/// <summary>Lays a setup-screen card out in the space <c>Border.card</c> gives its contents.</summary>
|
|
/// <remarks>
|
|
/// The card is <em>built</em> inside the dispatched call rather than passed in already constructed, and
|
|
/// that is not style. Avalonia binds <c>Dispatcher.UIThread</c> to whichever thread first asks for it, so
|
|
/// a control constructed on the test thread before any other test has dispatched makes that thread the
|
|
/// UI thread — and every later property set from the harness's own thread then throws. It depends on the
|
|
/// order the tests happen to run in, which is why it survived until a phase that added new ones.
|
|
/// </remarks>
|
|
private Task MeasureCardAsync(Func<Control> build) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var card = build();
|
|
card.DataContext = shell;
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);
|
|
|
|
try
|
|
{
|
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
// ---- Helpers ----
|
|
|
|
/// <summary>Lays the drawer out at the width it declares for itself.</summary>
|
|
private Task MeasureDrawerAsync(Action<IReadOnlyList<string>> assert) =>
|
|
OnTheDrawerAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
|
|
|
|
private Task OnTheDrawerAsync(Action<HostDrawer, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var drawer = new HostDrawer { DataContext = vault };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
drawer, LayoutHarness.HostDrawerWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
body(drawer, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <summary>Lays the hosts screen out at the size it gets beside the nav rail and under the strip.</summary>
|
|
/// <remarks>
|
|
/// The vault is the data context and the shell is not, which it used to be. The screen handed the vault
|
|
/// to the sidebar from inside its own markup and needed the shell to do it; the drawer is a plain child
|
|
/// and inherits what the screen has, so the indirection went away with the sidebar.
|
|
/// </remarks>
|
|
private Task MeasureHostsAsync(Action<IReadOnlyList<string>> assert) =>
|
|
OnTheHostsScreenAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
|
|
|
|
private Task OnTheHostsScreenAsync(Action<HostsScreen, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new HostsScreen { DataContext = vault };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
body(screen, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
|
|
/// <remarks>
|
|
/// The same width and height as a full screen: the card is a sibling of the page area rather than
|
|
/// something drawn inside one, so what it gets is everything under the tab strip and beside the rail.
|
|
/// </remarks>
|
|
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
shell.State = ShellState.Unlocked;
|
|
shell.Tabs.Clear();
|
|
shell.Tabs.Add(tab);
|
|
shell.SelectedTab = tab;
|
|
|
|
var card = new ConnectingCard { DataContext = shell };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
assert(LayoutHarness.Unreachable(window));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
|
/// <remarks>
|
|
/// The vault is the data context, as it is in the window, and the size is a screen's rather than a card's:
|
|
/// this control carries its own scrim and its own <c>Border.card</c>, so what it is handed is the area the
|
|
/// overlay covers and the card centres itself inside it. That area is in fact everything under the
|
|
/// titlebar, which is <see cref="LayoutHarness.TerminalTabsHeight"/> taller than what is used here —
|
|
/// measuring it at the tighter budget is deliberate, since a card that fits the strip's row too cannot
|
|
/// stop fitting when the strip is what it is drawn over.
|
|
/// </remarks>
|
|
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var card = new HostKeyCard { DataContext = vault };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
assert(LayoutHarness.Unreachable(window));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
|
|
private Task MeasureImportAsync(
|
|
Action<IReadOnlyList<string>> assert,
|
|
ImportViewModel? import = null) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new ImportScreen
|
|
{
|
|
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
|
|
};
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
assert(LayoutHarness.Unreachable(window));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <summary>
|
|
/// An import view model that has scanned a real file, so the table has rows in it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Through a temporary directory rather than by populating the rows directly, because the shape being
|
|
/// measured is what the parser produces — an entry with two warnings under it is taller than one
|
|
/// without, and inventing the rows would measure a layout nothing generates.
|
|
/// </remarks>
|
|
private async Task<ImportViewModel> ScannedImportAsync()
|
|
{
|
|
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-import-{Guid.CreateVersion7():N}");
|
|
Directory.CreateDirectory(directory);
|
|
|
|
try
|
|
{
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(directory, "config"),
|
|
"""
|
|
Host *
|
|
ServerAliveInterval 30
|
|
|
|
Host prod-db
|
|
HostName database.production.internal
|
|
User deploy
|
|
Port 2222
|
|
IdentityFile ~/.ssh/id_ed25519
|
|
|
|
Host bastion-eu-west-1
|
|
HostName bastion.eu-west-1.example.com
|
|
User ops
|
|
ProxyCommand nc %h %p
|
|
Compression yes
|
|
compression no
|
|
|
|
Match host anything
|
|
User root
|
|
""");
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
|
|
|
|
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
|
|
// table — which is the other test.
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
|
|
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
|
|
|
|
return import;
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>Lays the host keys screen out at the size it gets beside the nav rail.</summary>
|
|
private Task MeasurePinsAsync(
|
|
Action<IReadOnlyList<string>> assert,
|
|
KnownHostsViewModel? pins = null) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new KnownHostsScreen { DataContext = pins ?? new KnownHostsViewModel(vault) };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
assert(LayoutHarness.Unreachable(window));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <summary>Lays the logs screen out at the size it gets beside the nav rail.</summary>
|
|
private Task MeasureLogsAsync(
|
|
Action<IReadOnlyList<string>> assert,
|
|
LogSection section,
|
|
LogsViewModel? logs = null) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var model = logs ?? NewLogsScreen();
|
|
model.Section = section;
|
|
|
|
var screen = new LogsScreen { DataContext = model };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
assert(LayoutHarness.Unreachable(window));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
private LogsViewModel NewLogsScreen(params LiveConnection[] live) =>
|
|
new(session, () => live);
|
|
|
|
/// <summary>
|
|
/// Writes one of each kind of entry and reads them back.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Through the repositories the recorders write to, rather than through the recorders themselves: those
|
|
/// write on a background task on purpose, and a layout suite that waited on one would be measuring
|
|
/// rectangles behind a race.
|
|
/// </remarks>
|
|
private async Task<LogsViewModel> SeedLogsAsync()
|
|
{
|
|
await session.ConnectionLog.CreateAsync(
|
|
session.ActiveVaultId,
|
|
new ConnectionLogSecret
|
|
{
|
|
HostLabel = "customer-a-production-database",
|
|
Address = "deployment-account@db-01.customer-a.internal:22022",
|
|
StartedAt = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
|
|
Duration = TimeSpan.FromMinutes(74),
|
|
Outcome = ConnectionOutcome.Refused,
|
|
DeviceName = "jaap-jan-workstation",
|
|
},
|
|
Token);
|
|
|
|
await session.ActivityLog.CreateAsync(
|
|
session.ActiveVaultId,
|
|
new ActivityLogSecret
|
|
{
|
|
ItemKind = "Host",
|
|
ItemId = Guid.CreateVersion7(),
|
|
ItemLabel = "customer-a-production-database",
|
|
Operation = ActivityOperation.Updated,
|
|
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags",
|
|
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
|
|
DeviceName = "jaap-jan-workstation",
|
|
},
|
|
Token);
|
|
|
|
var logs = NewLogsScreen(new LiveConnection(
|
|
"customer-a-production-database",
|
|
"deployment-account@db-01.customer-a.internal:22022",
|
|
new DateTimeOffset(2026, 7, 31, 8, 0, 0, TimeSpan.Zero),
|
|
"jaap-jan-workstation"));
|
|
|
|
await logs.ReloadAsync(Token);
|
|
|
|
return logs;
|
|
}
|
|
|
|
/// <summary>Lays the snippets screen out at the size it gets beside the nav rail.</summary>
|
|
/// <remarks>
|
|
/// The insert function throws. Nothing measured here presses a button, and a substitute that returned a
|
|
/// plausible answer would make it possible to write a layout test that quietly exercised the transport.
|
|
/// </remarks>
|
|
private Task MeasureSnippetsAsync(
|
|
Action<IReadOnlyList<string>> assert,
|
|
SnippetsViewModel? snippets = null) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new SnippetsScreen { DataContext = snippets ?? NewSnippetsScreen() };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
assert(LayoutHarness.Unreachable(window));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
|
|
new(
|
|
vault,
|
|
() => target ?? InsertTarget.None,
|
|
static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));
|
|
|
|
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
|
|
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new TransfersScreen { DataContext = transfers };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
assert(LayoutHarness.Unreachable(window));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <summary>Puts one transfer on the queue in a given state, without moving a byte.</summary>
|
|
private void Enqueue(
|
|
TransferDirection direction,
|
|
string name,
|
|
long length,
|
|
long transferred,
|
|
TransferState state,
|
|
double bytesPerSecond = 0,
|
|
string? failure = null) =>
|
|
transfers.Transfers.Add(new TransferRowViewModel(new TransferSnapshot(
|
|
Guid.CreateVersion7(),
|
|
direction,
|
|
name,
|
|
Path.Combine(Path.GetTempPath(), name),
|
|
SftpPath.Combine("/srv/releases", name),
|
|
length,
|
|
transferred,
|
|
state,
|
|
bytesPerSecond,
|
|
failure)));
|
|
|
|
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
|
|
/// takes 190 and the vault list 268, leaving 558 for everything above.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
|
|
/// <see cref="StubTeamServer"/>. The two states that hide half the screen, the rename form and the
|
|
/// confirmation, are measured in their own tests below rather than here, because a control that is
|
|
/// collapsed when the window is laid out is a control this suite has not checked.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public Task TheVaultsScreen_FitsWithEveryListPopulated() =>
|
|
OnTheVaultsScreenAsync(
|
|
vaults => { },
|
|
window => LayoutHarness.Unreachable(window)
|
|
.ShouldBeEmpty("the vaults screen with members and key holders"));
|
|
|
|
/// <remarks>
|
|
/// The rename form is drawn in place, above the members list, and pushes everything below it down.
|
|
/// </remarks>
|
|
[Fact]
|
|
public Task TheVaultsScreen_FitsWhileRenamingAVault() =>
|
|
OnTheVaultsScreenAsync(
|
|
vaults => vaults.RenameVaultCommand.Execute(null),
|
|
window => LayoutHarness.Unreachable(window)
|
|
.ShouldBeEmpty("the vaults screen with the rename form open"));
|
|
|
|
/// <remarks>
|
|
/// The name-a-vault form is in the left column under the vault list. Worth its own case because the
|
|
/// column is 268 wide and the sentence under the field wraps.
|
|
/// </remarks>
|
|
[Fact]
|
|
public Task TheVaultsScreen_FitsWithTheNewVaultFormOpen() =>
|
|
OnTheVaultsScreenAsync(
|
|
vaults => vaults.NewVaultCommand.Execute(null),
|
|
window => LayoutHarness.Unreachable(window)
|
|
.ShouldBeEmpty("the vaults screen with the new-vault form open"));
|
|
|
|
/// <remarks>
|
|
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
|
|
/// tallest thing that can appear above the members list, so it is the case most likely to push the
|
|
/// key-holders list off the bottom.
|
|
/// </remarks>
|
|
[Fact]
|
|
public Task TheVaultsScreen_FitsWhileConfirmingAHandOver() =>
|
|
OnTheVaultsScreenAsync(
|
|
vaults =>
|
|
{
|
|
vaults.SelectedMember = vaults.Members.First(member => !member.IsSelf);
|
|
vaults.HandOverCommand.Execute(null);
|
|
},
|
|
window => LayoutHarness.Unreachable(window)
|
|
.ShouldBeEmpty("the vaults screen with the hand-over confirmation armed"));
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A real <c>VaultsViewModel</c> over this suite's own unlocked session and a stub server. Both halves
|
|
/// are needed and they answer different questions: the vault list is the session's, and who is in each
|
|
/// vault is the server's.
|
|
/// </para>
|
|
/// <para>
|
|
/// A shared vault is created into the session first, because a session that has only ever been unlocked
|
|
/// offline holds one personal vault — and the personal vault draws none of what this screen is for. It
|
|
/// is created through the real <c>CreateTeamVaultAsync</c> rather than poked into the cache, so the row
|
|
/// being measured is one the application could actually produce.
|
|
/// </para>
|
|
/// <para>
|
|
/// Selected before the second load rather than after it, so the members read is the awaited one: a
|
|
/// selection assignment starts a read nothing can wait for, and measuring a window while it was still
|
|
/// in flight would certify a screen with empty lists.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task OnTheVaultsScreenAsync(
|
|
Action<VaultsViewModel> arrange,
|
|
Action<Window> assert)
|
|
{
|
|
using var teamServer = new StubTeamServer();
|
|
|
|
await session.CreateTeamVaultAsync(
|
|
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
|
|
|
var vaults = new VaultsViewModel(() => teamServer, () => session);
|
|
|
|
await vaults.LoadAsync(Token);
|
|
|
|
vaults.SelectedVault = vaults.Vaults.First(row => row.IsShared);
|
|
|
|
await vaults.LoadAsync(Token);
|
|
|
|
vaults.Members.ShouldNotBeEmpty("there is nothing to measure otherwise");
|
|
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
arrange(vaults);
|
|
|
|
var screen = new VaultsScreen { DataContext = vaults };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
assert(window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
|
|
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
|
|
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
|
|
|
|
private Task OnTheVaultAsync(Action<KeychainScreen, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new KeychainScreen { 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()
|
|
{
|
|
// Before the hosts, so the tag picker in the host editor has chips in it and the rows below have
|
|
// chips on them. A vault with no tags collapses both, and a collapsed control is the shape this
|
|
// suite is least interested in certifying.
|
|
//
|
|
// Ten of them, and the number is doing work: the picker is a chip per tag, wrapped, so this is what
|
|
// drives the sidebar's editor onto the MaxHeight of the ScrollViewer it gained in the same change —
|
|
// and that capped pane is the thing which has to fit the column. Three tags left it short of the cap,
|
|
// measuring a shape no user with a real keychain ever sees.
|
|
foreach (var label in new[]
|
|
{
|
|
"pci", "eu-west-1", "postgres-16", "on-call", "customer-a", "bastion",
|
|
"kubernetes-worker", "legacy", "staging", "database-primary",
|
|
})
|
|
{
|
|
vault.NewTagCommand.Execute(null);
|
|
vault.TagEditorLabel = label;
|
|
await vault.SaveTagCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
for (var i = 0; i < 6; i++)
|
|
{
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = $"host-{i}";
|
|
vault.EditorHostname = $"host-{i}.internal";
|
|
vault.EditorUsername = "deploy";
|
|
|
|
// Every tag on the first host, so the widest realistic row — a chip row that wraps — is what
|
|
// gets measured rather than a bare label.
|
|
foreach (var choice in vault.EditorTagChoices.Where(_ => i == 0).ToList())
|
|
{
|
|
vault.ToggleEditorTagCommand.Execute(choice);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds snippets, including the two shapes that decide this screen's height.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Not part of <see cref="SeedAsync"/>, so the empty state stays measurable — and because most keychains
|
|
/// have none, which is the shape somebody sees the first time they open the screen.
|
|
/// </remarks>
|
|
private async Task SeedSnippetsAsync()
|
|
{
|
|
await vault.SaveSnippetAsync(
|
|
vault.TargetVaultId,
|
|
null,
|
|
new SnippetSecret
|
|
{
|
|
Label = "tail the application log",
|
|
Command = "sudo journalctl -u dodossh-api -f --since '10 minutes ago'",
|
|
Notes = "Ctrl+C to stop.",
|
|
},
|
|
Token);
|
|
|
|
await vault.SaveSnippetAsync(
|
|
vault.TargetVaultId,
|
|
null,
|
|
new SnippetSecret
|
|
{
|
|
Label = "restart the api",
|
|
Command = "sudo systemctl daemon-reload\nsudo systemctl restart dodossh-api\nsystemctl status dodossh-api --no-pager",
|
|
Notes = "Check the on-call rota before running this in production.",
|
|
RunsOnInsert = true,
|
|
},
|
|
Token);
|
|
|
|
vault.Snippets.Count.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds groups and files the seeded hosts across them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Not part of <see cref="SeedAsync"/>, on purpose. A vault with no groups is what a new one is and what
|
|
/// most of them stay, and it is the shape in which the sidebar draws no headings at all — so it has to
|
|
/// remain the one every other test here measures.
|
|
/// </remarks>
|
|
private async Task SeedGroupsAsync(int count)
|
|
{
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
vault.GroupEditorLabel = $"customer-{i}-production";
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
vault.Groups.Count.ShouldBe(count);
|
|
|
|
// Filed through the host editor, which is the only way a user can do it, so this also exercises the
|
|
// picker the sidebar's headings are built out of.
|
|
for (var i = 0; i < vault.Hosts.Count; i++)
|
|
{
|
|
vault.SelectedHost = vault.Hosts[i];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorSelectedGroup = vault.EditorGroupChoices
|
|
.First(choice => choice.EntityId == vault.Groups[i % count].EntityId);
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
}
|
|
}
|