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:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
@@ -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);
}
}
}