Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -4,10 +4,12 @@ using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.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;
|
||||
@@ -179,6 +181,24 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Headings are rows in the same list as the hosts, drawn from a different template, and they are the
|
||||
/// widest thing in a 268-pixel column: a name, a chevron and a count on one line. Measured with one group
|
||||
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostSidebarFitsWithGroupHeadingsInTheList()
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
|
||||
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
|
||||
|
||||
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
|
||||
|
||||
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes
|
||||
@@ -302,12 +322,108 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
|
||||
vault.Status.ShouldContain(
|
||||
"not in this vault any more",
|
||||
"not in this keychain any more",
|
||||
Case.Insensitive,
|
||||
"the double-click has to reach the connect command");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 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.
|
||||
|
||||
[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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileAHostKeyIsBeingApproved()
|
||||
{
|
||||
vault.PendingHostKey = new HostKeyPresentation(
|
||||
"db.internal", 22, "ssh-ed25519", "SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the unknown-key prompt up"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileAHostKeyIsRefused()
|
||||
{
|
||||
vault.HostKeyMismatch =
|
||||
"db.internal:22 presented ssh-ed25519 SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0, "
|
||||
+ "and this keychain has SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI pinned for it.";
|
||||
|
||||
await MeasureHostsAsync(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 panel is a row of its own at the foot of this screen, so it competes with the overview above
|
||||
/// it for the same column — and it grows sideways as groups are added, which is the direction a
|
||||
/// fixed-width column has least of. Six, because that is more than anybody's first three and enough to
|
||||
/// need the horizontal scroller rather than to overflow silently.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithMoreGroupsThanTheRowHasRoomFor()
|
||||
{
|
||||
await SeedGroupsAsync(6);
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six groups along the bottom"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The question replaces the buttons rather than stacking under them — the same rule the sidebar's own
|
||||
/// deletion follows — and it is the taller of the two, because it says how many hosts are about to move.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.SelectedGroup = vault.Groups[0];
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
|
||||
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up"));
|
||||
}
|
||||
|
||||
// ---- The vault screen ----
|
||||
|
||||
[Fact]
|
||||
@@ -315,7 +431,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
{
|
||||
foreach (var section in new[]
|
||||
{
|
||||
VaultSection.All, VaultSection.Keys, VaultSection.Credentials, VaultSection.KnownHosts,
|
||||
VaultSection.All, VaultSection.Keys, VaultSection.Credentials,
|
||||
})
|
||||
{
|
||||
vault.Section = section;
|
||||
@@ -353,21 +469,197 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// 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 TheVaultScreenFitsWithAPinSelected()
|
||||
public async Task TheVaultScreenFitsWithTheGenerateFormOpen()
|
||||
{
|
||||
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();
|
||||
vault.NewGeneratedKeyCommand.Execute(null);
|
||||
vault.IsGeneratingKey.ShouldBeTrue();
|
||||
|
||||
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <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 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 eleven of them.
|
||||
/// Measured with an edit that touched several, because one field name fits anywhere.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
[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
|
||||
@@ -574,12 +866,15 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Eight 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 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.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheNavRailHoldsFiveDestinationsAtTheWindowsMinimum()
|
||||
public async Task TheNavRailHoldsEightDestinationsAtTheWindowsMinimum()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
@@ -592,7 +887,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
{
|
||||
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
|
||||
|
||||
buttons.Count.ShouldBe(5, "one per screen the rail reaches");
|
||||
buttons.Count.ShouldBe(8, "one per screen the rail reaches");
|
||||
|
||||
foreach (var button in buttons)
|
||||
{
|
||||
@@ -640,7 +935,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
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(new UnlockCard());
|
||||
await MeasureCardAsync(static () => new UnlockCard());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -698,14 +993,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
shell.LiveSessionCount = 1;
|
||||
shell.Transfers.IsConnected = true;
|
||||
|
||||
await MeasureCardAsync(new SignOutCard());
|
||||
await MeasureCardAsync(static () => new SignOutCard());
|
||||
}
|
||||
|
||||
/// <summary>Lays a setup-screen card out in the space <c>Border.card</c> gives its contents.</summary>
|
||||
private Task MeasureCardAsync(Control card) =>
|
||||
/// <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(
|
||||
@@ -748,6 +1051,250 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the hosts screen out at the size it gets beside the nav rail and under the strip.</summary>
|
||||
/// <remarks>
|
||||
/// The shell is the data context, not the vault — the sidebar is handed the vault from inside the
|
||||
/// screen's own markup. <see cref="MainWindowViewModel.Vault"/> is assigned rather than reached through
|
||||
/// an unlock, which would be a second enrollment for no extra rectangle.
|
||||
/// </remarks>
|
||||
private Task MeasureHostsAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.Vault = vault;
|
||||
shell.State = ShellState.Unlocked;
|
||||
|
||||
var screen = new HostsScreen { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, 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, Group",
|
||||
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(
|
||||
@@ -868,4 +1415,69 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
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(
|
||||
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(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user