Public Access
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding.
This commit is contained in:
@@ -6,6 +6,8 @@ using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.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.Ssh;
|
||||
@@ -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