using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Threading; using Avalonia.VisualTree; using DodoSSH.Client.App.Views; using DodoSSH.Client.Domain; using DodoSSH.Client.Import; using DodoSSH.Client.Session; using DodoSSH.Client.Session.Tests; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using DodoSSH.Client.Transfer; using DodoSSH.Crypto; using NSubstitute; namespace DodoSSH.Client.App.Layout.Tests; /// /// Whether each screen fits in the space the window gives it. /// /// /// /// This suite used to measure one control, VaultColumn, because there was one. The design import /// split it in two — the host list lives beside the terminal, and everything else in the vault has a screen /// of its own — and added a titlebar, a nav rail and a status bar. That is five things to measure, and the /// split is what keeps every one of them measurable: none contains the terminal's WebView, and /// MainWindow still cannot be laid out here at all, because WebView2's adapter refuses the headless /// dispatcher's MTA thread. pins that. /// /// /// One test per shape a user can put a screen into, because a shape that is never laid out is a shape never /// checked. The host sidebar has three — list, list with the editor open, and list folded away — and the /// vault screen has one per category plus one per editor. /// /// /// A real VaultViewModel over a real unlocked vault, rather than a stand-in. Compiled bindings /// resolve against the declared data type, so a stand-in would have to be the same type anyway — and the /// editors' height depends on real content: a key with a real armour block in the box is taller than an /// empty one. /// /// public sealed class ScreenLayoutTests : IAsyncLifetime { private const string Passphrase = "a sufficiently long passphrase"; private const string ServerUrl = "https://dodossh.example"; /// Far below the shipped profile: nothing here attacks a wrap. private static readonly Argon2Profile CheapProfile = Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); private readonly FakeAccountServer server = new(); private readonly StubKeyBinding keyBinding = new(); private readonly VaultKnownHostStore knownHosts = new(); private ClientCacheFactory caches = null!; private TerminalWorkspace workspace = null!; private VaultSession session = null!; private VaultViewModel vault = null!; /// /// Constructed and never started: the sign-out card binds to the shell rather than to a vault, and what /// it shows comes from properties a fresh one already answers. Starting it would migrate a cache and /// read a profile, neither of which any rectangle here depends on. /// private MainWindowViewModel shell = null!; /// /// Over a substitute factory that is never asked for a session. Every shape measured here is one the /// screen is in before a connection exists or after one has failed, which is deliberate: the two panes /// are at their widest with the local one full and the remote one carrying its explanation, and a /// connected pane is the same template with shorter names in it. /// private TransfersViewModel transfers = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() { caches = ClientCacheFactory.ForMemory($"layout-{Guid.CreateVersion7():N}"); await caches.MigrateAsync(Token); await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile) .EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token); var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); session = outcome.Session!; // Never started and never connected through: no screen's layout depends on the terminal, and the // substitute is here only because the view model's constructor asks for one. workspace = new TerminalWorkspace( new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)), Substitute.For(), TimeProvider.System); await knownHosts.OpenAsync(session, Token); // Offline. A null connection is what these screens show on a laptop with no network, and it keeps // every sync pass out of a suite that is only measuring rectangles. vault = new VaultViewModel(session, workspace, knownHosts, static () => null); shell = new MainWindowViewModel( new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-layout-{Guid.CreateVersion7():N}")), caches, workspace, knownHosts, new UnavailableDeviceKeyStore(), static (_, _) => throw new InvalidOperationException("A layout test has no network."), TimeProvider.System, Substitute.For(), CheapProfile); transfers = new TransfersViewModel( Substitute.For(), TimeProvider.System); await SeedAsync(); // Attached after seeding, so the host picker has something in it and the local pane has listed this // machine's home directory — which is what puts real names of real length into the row template. transfers.Attach(vault, knownHosts); } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync(); await transfers.DisposeAsync(); await vault.DisposeAsync(); knownHosts.Close(); await workspace.DisposeAsync(); await session.DisposeAsync(); caches.Dispose(); } // ---- The hosts drawer ---- // // This was the host sidebar's section. The control kept the half of that column that is about one host // and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on // the right. /// /// Opened through the command rather than by assigning the selection, which is the whole of what changed /// when the pencil arrived: a selected host no longer puts the pane up, so a test that only selected one /// would measure a drawer with all three panels collapsed and pass on an empty column. See /// VaultViewModel.IsHostPaneOpen. /// [Fact] public async Task TheHostDrawerFitsShowingAHost() { vault.OpenHostPaneCommand.Execute(vault.Hosts[0]); vault.IsShowingHostDetail.ShouldBeTrue("there is nothing to measure otherwise"); await MeasureDrawerAsync(faults => faults.ShouldBeEmpty()); } /// /// The tight one, and the reason this suite still exists. The host editor is the tallest thing the /// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a /// wrapped row of tag chips, a checkbox, a paragraph of hint text and three buttons. /// [Fact] public async Task TheHostDrawerFitsWithTheHostEditorOpen() { vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.EditorAuthenticationChoices.Count .ShouldBeGreaterThan(1, "the picker has to be populated for this to measure anything"); // Measured with a credential selected, because an empty picker is shorter than one showing a // qualifier beside a label. vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices .First(choice => choice.Kind is AuthenticationKind.Credential); await MeasureDrawerAsync(faults => faults.ShouldBeEmpty()); } /// /// The other editor, and it is in this control for the first time: the desktop's group editor used to be /// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its /// three pickers are the same width as the host editor's and its labels are longer. /// [Fact] public async Task TheHostDrawerFitsWithTheGroupEditorOpen() { await SeedGroupsAsync(3); vault.GroupFilter = vault.Groups[0]; vault.EditGroupCommand.Execute(null); vault.IsEditingGroup.ShouldBeTrue("the desktop raises this now, as the phone always did"); await MeasureDrawerAsync(faults => faults.ShouldBeEmpty()); } /// /// /// The question in place of the three buttons. Its tallest shape is a host with a terminal open on it, /// which adds a disclosure the ordinary case has not got. /// /// /// Still worth measuring although the drawer scrolls as a whole now — see HostDrawer.axaml — and /// the reason has changed rather than gone. The harness skips anything inside a ScrollViewer, so /// what this holds is not that the buttons are on screen but that the drawer itself does not blow its /// column sideways. The question is the widest thing it draws: a sentence with a host name in it. /// /// [Fact] public async Task TheHostDrawerFitsWithADeletionInQuestion() { vault.OpenHostPaneCommand.Execute(vault.Hosts[0]); vault.SelectedHost.ShouldNotBeNull().IsConnected = true; vault.DeleteHostCommand.Execute(null); vault.IsConfirmingDeletion.ShouldBeTrue(); vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape"); await MeasureDrawerAsync(faults => faults.ShouldBeEmpty()); } /// /// /// The move panel, which takes the footer as the deletion question does and is the taller of the two: a /// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is /// the risk — it is what says the group and the tags stay behind — and the footer is one of the two /// parts of this drawer that is not inside a ScrollViewer, so nothing brings it back into view. /// /// /// The state is set here rather than through MoveHostCommand, which would refuse: this fixture's /// account holds one vault, and the command declines rather than open a picker with nothing in it. What /// this test is about is the rectangle, and the flow that fills it is covered in /// DodoSSH.Client.App.Tests. /// /// [Fact] public async Task TheHostDrawerFitsWithTheMovePanelOpen() { vault.OpenHostPaneCommand.Execute(vault.Hosts[0]); vault.MoveVaultChoices.Add( new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false)); vault.SelectedMoveVault = vault.MoveVaultChoices[0]; vault.IsMovingHost = true; vault.ShowsHostPaneActions.ShouldBeFalse("the panel takes the footer rather than sharing it"); await MeasureDrawerAsync(faults => faults.ShouldBeEmpty()); } /// /// /// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it. /// The gesture is wired in the control rather than bound in the markup, which is exactly the sort of /// wiring that compiles whether or not it is connected to anything — so it is worth a test that /// performs the gesture. /// /// /// Proved through a connection that is refused before any network is involved. The host is left bound /// to a key that has been deleted, which TryBuildAuthentication turns into a sentence on the /// status line rather than a socket — so what this asserts is that the command ran, with nothing /// timing out to make it flaky. /// /// [Fact] public async Task DoubleClickingAHostConnectsToIt() { var keyId = vault.Keys[0].EntityId; vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices .Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId); await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedKey = vault.Keys.Single(row => row.EntityId == keyId); vault.DeleteKeyCommand.Execute(null); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.SelectedHost = null; vault.Status = string.Empty; await OnTheHostsScreenAsync((screen, window) => { var card = screen.HostGrid.GetVisualDescendants() .OfType() .First(item => item.DataContext is HostRowViewModel); var centre = card.TranslatePoint( new Point(card.Bounds.Width / 2, card.Bounds.Height / 2), window) ?? throw new InvalidOperationException("the card is not in this window's tree"); window.MouseDown(centre, MouseButton.Left); window.MouseUp(centre, MouseButton.Left); window.MouseDown(centre, MouseButton.Left); window.MouseUp(centre, MouseButton.Left); Dispatcher.UIThread.RunJobs(); vault.SelectedHost.ShouldNotBeNull("a press on a card selects it"); vault.Status.ShouldContain( "not in this keychain any more", Case.Insensitive, "the double-click has to reach the connect command"); }); } /// /// /// The one thing a wrong answer here breaks is unrecoverable from the keyboard: MainWindow takes /// the keyboard off the terminal's native child window first and then focuses this target, so a target /// that cannot take focus leaves the user with no focused element and no way back except the mouse. /// /// /// Which is why this asserts that focus was taken rather than that the right control was named. /// A ListBox is not focusable by default, so the call returns false against a list that has not /// asked to be — and an empty one has no item to take it either, which is the second shape below. /// /// /// The empty shape used to be the sidebar's folded-away list and is now a filter that matches nothing. /// That is a state a user reaches far more often than the old one: it is one keystroke away from every /// search. /// /// [Fact] public async Task TheHostsScreensKeyboardTargetTakesFocusInBothOfItsShapes() { await OnTheHostsScreenAsync((screen, _) => { screen.KeyboardTarget.ShouldBeSameAs(screen.HostGrid); screen.KeyboardTarget.Focus().ShouldBeTrue("the grid has cards in it"); }); vault.HostFilter = "nothing matches this"; vault.HasVisibleHosts.ShouldBeFalse(); await OnTheHostsScreenAsync((screen, _) => { screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter); screen.KeyboardTarget.Focus().ShouldBeTrue("the grid is empty, so the find box takes it"); }); } /// /// The editor open with the grid still on screen beside it, which is the state a user is most likely to /// leave this screen in — so it is the state the keyboard answer most has to hold in. /// [Fact] public async Task TheHostsScreensKeyboardTargetStillTakesFocusWithTheEditorOpen() { vault.NewHostCommand.Execute(null); await OnTheHostsScreenAsync((screen, _) => { screen.HostGrid.IsEffectivelyVisible.ShouldBeTrue(); screen.KeyboardTarget.Focus().ShouldBeTrue(); }); } /// /// The grid is cards and nothing else, whatever the vault has been filed into. /// /// /// /// It used to hold the group headings too — a full-width fold-away bar between the cards for each group /// — and in a wrap of cards that read as a dropdown somebody had left open. They are the phone's now. /// This asserts the grid's contents rather than only measuring them, because a heading that came back /// would lay out perfectly cleanly: the harness asks whether a control is inside the window, and a bar /// that spans the width is as inside it as a card is. /// /// /// Measured with every host filed and that group open, which is the shape that used to produce the most /// headings. Open, because the grid holds one level of the tree — a host inside a group is drawn inside /// that group and nowhere else — so measuring at the outermost level would be measuring an empty grid. /// See VaultViewModel.Matches. /// /// [Fact] public async Task TheHostsGridHoldsCardsAndNoGroupHeadings() { await SeedGroupsAsync(3); foreach (var host in vault.Hosts.ToArray()) { await vault.MoveHostToGroupCommand.ExecuteAsync( new HostGroupMove(host, vault.Groups[0].EntityId)); } vault.OpenGroupCommand.Execute(vault.Groups[0]); await OnTheHostsScreenAsync((screen, _) => { var rows = screen.HostGrid .GetVisualDescendants() .OfType() .Select(item => item.DataContext) .ToList(); rows.ShouldNotBeEmpty("the seed has to put hosts in the grid"); rows.ShouldAllBe(row => row is HostRowViewModel); }); await MeasureHostsAsync( faults => faults.ShouldBeEmpty("with every host filed and the group holding them open")); } /// /// The narrowest the grid ever gets, and the width the tile was sized against: the window at its /// minimum, less the nav rail and less the drawer. /// [Fact] public async Task TheHostsScreenFitsWithTheDrawerOpen() { vault.OpenHostPaneCommand.Execute(vault.Hosts[0]); vault.IsDrawerOpen.ShouldBeTrue(); await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out")); } /// /// The grid is still a grid at the window's minimum with the drawer open. /// /// /// /// The harness cannot see this and never will. Its one rule is that a control is inside the /// window, so a wrap that has quietly collapsed to a single column reports perfectly clean — every card /// is inside, just one above the other. That is exactly what happened: the tile's width was set from /// arithmetic that left out the scrolling stack's own margins, and the grid became a list with extra /// padding at precisely the size this application guarantees. /// /// /// Two per row rather than a width assertion, because the number that matters is the number of columns. /// A width is one of the inputs — the margins, the padding and the scrollbar are the others — and /// pinning the input would go on passing while any of the rest moved. /// /// [Fact] public async Task TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen() { vault.OpenHostPaneCommand.Execute(vault.Hosts[0]); vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away"); await OnTheHostsScreenAsync((screen, window) => { var cards = screen.HostGrid .GetVisualDescendants() .OfType() .Where(item => item.DataContext is HostRowViewModel) .Select(item => item.TranslatePoint(default, window) ?? throw new InvalidOperationException("a card is not in this window's tree")) .ToList(); cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host in the grid"); cards.GroupBy(point => Math.Round(point.Y)) .Max(row => row.Count()) .ShouldBeGreaterThanOrEqualTo( 2, "at the window's minimum, with the drawer out, the cards still wrap two to a row"); }); } /// /// The widest the drawer's own contents get while the grid is beside them: the host editor open, which /// is what EDIT does to a screen that already has both columns up. /// [Fact] public async Task TheHostsScreenFitsWithTheDrawerEditingAHost() { vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the editor out beside the grid")); } // ---- The hosts screen ---- // // Measurable for the first time. Every rectangle below lived in MainWindow.axaml until the terminal // moved out from under it, and nothing in that window can be laid out here — so the connect banner, the // two host key prompts and the conflict log had never been through this harness at all. They are also // the four worst candidates for that: each appears only in a state somebody has to reproduce by hand. [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")); } /// /// Twenty, because one is not the case that broke. The log sits on an Auto row above the overview, /// and an ItemsControl 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 ScrollViewer and MaxHeight in /// HostsScreen.axaml, and this is what holds them there. /// [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")); } /// /// The group cards are a wrap above the host cards, so more of them than a row holds is the case that /// pushes the hosts down rather than one that overflows sideways. Six, because that is more than /// anybody's first three and enough to need a second row at the window's minimum. /// [Fact] public async Task TheHostsScreenFitsWithMoreGroupsThanARowHasRoomFor() { await SeedGroupsAsync(6); await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six group cards above the hosts")); } /// /// The question opens under the GROUPS heading and pushes the cards down, and it is the tallest thing /// this section draws: a heading, a consequence, a boxed count, and now a tick with a sentence beside it /// asking whether the machines go too. The tick is the part worth measuring, because it is a wrapping /// paragraph inside a control whose own height the layout does not obviously account for. /// [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"); vault.PendingDeletion.ShouldNotBeNull().HasChoice .ShouldBeTrue("the hosts filed under it are what makes this the long shape"); await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up")); } /// /// /// The group's move panel, under the GROUPS heading beside the deletion question and the wordier of the /// two: a heading, a combo box, a wrapping paragraph naming everything that travels and everything that /// does not, and two buttons — above a wrap of group cards and the host grid, all of which still have to /// fit under it. /// /// /// The state is set here rather than through MoveGroupCommand, which would refuse: this fixture's /// account holds one vault, and the command declines rather than open a picker with nothing in it. The /// flow that fills it is covered in DodoSSH.Client.App.Tests. /// /// [Fact] public async Task TheHostsScreenFitsWithTheGroupMovePanelOpen() { await SeedGroupsAsync(3); vault.SelectedGroup = vault.Groups[0]; vault.MoveGroupVaultChoices.Add( new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false)); vault.SelectedMoveGroupVault = vault.MoveGroupVaultChoices[0]; vault.IsMovingGroup = true; vault.IsConfirmingGroupDeletion.ShouldBeFalse("the two panels share the space and never the moment"); await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up")); } // ---- The vault screen ---- [Fact] public async Task TheKeychainScreenFitsInEveryCategory() { foreach (var section in new[] { VaultSection.All, VaultSection.Keys, VaultSection.Credentials, VaultSection.Tags, VaultSection.Buckets, }) { vault.Section = section; await MeasureVaultAsync(faults => faults.ShouldBeEmpty($"the {section} category")); } } /// /// The tall one: a private key needs a real text area, and the vault screen's detail pane is 244 pixels /// wide — the narrowest column any form in this application has to fit into. /// [Fact] public async Task TheKeychainScreenFitsWithTheKeyEditorOpen() { vault.NewKeyCommand.Execute(null); vault.IsEditingKey.ShouldBeTrue(); vault.ShowsKeys.ShouldBeTrue("opening an editor has to bring its own category into view"); vault.KeyEditorPrivateKey = string.Join( '\n', Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6)); await MeasureVaultAsync(faults => faults.ShouldBeEmpty()); } [Fact] public async Task TheKeychainScreenFitsWithThePasswordEditorOpen() { vault.NewCredentialCommand.Execute(null); vault.IsEditingCredential.ShouldBeTrue(); vault.ShowsCredentials.ShouldBeTrue("opening an editor has to bring its own category into view"); await MeasureVaultAsync(faults => faults.ShouldBeEmpty()); } /// /// 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. /// [Fact] public async Task TheKeychainScreenFitsWithTheGenerateFormOpen() { vault.NewGeneratedKeyCommand.Execute(null); vault.IsGeneratingKey.ShouldBeTrue(); await MeasureVaultAsync(faults => faults.ShouldBeEmpty()); } /// /// The S3 screen before there is a bucket to open, which is the state every new account starts in and /// the state whose contents changed: a heading, a paragraph and a button where an empty picker used to /// be. The paragraph is the risk — it is what says a bucket is a keychain item — and it sits in the /// 320-pixel invitation column with no scroll viewer above it. /// [Fact] public async Task TheS3ScreenFitsWithNoBucketsToOpen() { transfers.Remote = RemoteKind.Bucket; transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it"); await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with nothing to open yet")); } /// /// 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 IsHitTestVisible="False": an /// overlay that hit-tests lays out identically and swallows the events that would clear it. That one is /// in docs/manual-checks.md. /// [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")); } /// /// 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. /// [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")); } /// /// 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. /// [Fact] public async Task TheHostKeysScreenFitsWithPinsAndOneSelected() { var pins = new KnownHostsViewModel(vault); pins.VisiblePins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here"); pins.Selected = pins.VisiblePins[0]; await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with a pin selected"), pins); } [Fact] public async Task TheHostKeysScreenFitsWhenTheFilterMatchesNothing() { var pins = new KnownHostsViewModel(vault) { Filter = "no such fingerprint" }; pins.VisiblePins.ShouldBeEmpty(); await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), pins); } // ---- The connecting card ---- // // It fills the terminal's own rectangle, which is the one part of this window no other test can lay out: // the WebView it stands in for cannot be attached here at all. That makes it worth measuring for exactly // the reason the harness exists — its two buttons are the only way out of a connection that is not // going to happen. [Fact] public async Task TheConnectingCardFitsWhileAConnectionIsBeingMade() { await MeasureConnectingAsync( faults => faults.ShouldBeEmpty("while connecting"), new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22")); } /// /// The taller of the two shapes, and the one with something variable in it: a refusal is whatever the /// SSH layer said, which is a sentence rather than a word. /// [Fact] public async Task TheConnectingCardFitsWithARefusalInIt() { var tab = new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22"); tab.Failed( "Permission denied (publickey,keyboard-interactive). The server closed the connection after " + "three attempts."); await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab); } // ---- The logs screen ---- [Fact] public async Task TheLogsScreenFitsWithNeitherLogWrittenTo() { await MeasureLogsAsync(faults => faults.ShouldBeEmpty("the empty state"), LogSection.Connections); } /// /// 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. /// [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); } /// /// The FIELDS column is the one that grows: it is a list of names, and a host has thirteen of them — /// eleven until inheritance added "Password prompt" and tags added "Tags". Measured with an edit that /// touched several, because one field name fits anywhere. The count is stated rather than derived, so /// it has to be recounted against HostKind.Changes whenever a field is added; the literal in /// SeedLogsAsync is the thing that actually keeps the column measured at its worst. /// [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")); } /// /// 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. /// [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); } /// /// 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. /// [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); } /// /// /// The move panel, which is how a snippet gets shared and takes the insert controls' place while it is /// up: a heading, a combo box, a wrapping paragraph and two buttons, in the same 300-pixel column the /// detail pane has. The paragraph is the risk — it is what says who can read the command afterwards. /// /// /// The state is set here rather than through MoveCommand, which would refuse: this fixture's /// account holds one vault, and the command declines rather than open a picker with nothing in it. The /// flow that fills it is covered in DodoSSH.Client.App.Tests. The same arrangement, and the same /// reason, as . /// /// [Fact] public async Task TheSnippetsScreenFitsWithTheMovePanelOpen() { await SeedSnippetsAsync(); var snippets = NewSnippetsScreen(); snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert); snippets.MoveVaultChoices.Add( new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false)); snippets.SelectedMoveVault = snippets.MoveVaultChoices[0]; snippets.IsMoving = true; snippets.ShowsSelectionActions.ShouldBeFalse("the panel takes the pane rather than sharing it"); await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the move panel open"), snippets); } [Fact] public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing() { await SeedSnippetsAsync(); var snippets = NewSnippetsScreen(); snippets.Filter = "no such command"; snippets.Visible.ShouldBeEmpty(); await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), snippets); } /// /// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several /// hosts authenticate with, which is three sentences and a box in the narrowest column in the /// application. /// [Fact] public async Task TheKeychainScreenFitsWithADeletionInQuestion() { var keyId = vault.Keys[0].EntityId; foreach (var host in vault.Hosts.Take(4).ToList()) { vault.SelectedHost = host; vault.EditSelectedHostCommand.Execute(null); vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices .Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId); await vault.SaveHostCommand.ExecuteAsync(null); } vault.Section = VaultSection.Keys; vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == keyId); vault.DeleteSelectedItemCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull().HasUsage .ShouldBeTrue("four bound hosts are what makes this the long shape"); await OnTheVaultAsync((screen, window) => { LayoutHarness.Unreachable(window).ShouldBeEmpty(); // And it says something. A card whose bindings did not resolve would lay out perfectly as three // empty rows, which is the one failure a fit test cannot see: compiled bindings against the // wrong data type are a logged message rather than an exception. var card = screen.GetVisualDescendants().OfType().ShouldHaveSingleItem(); var said = string.Join( " ", card.GetVisualDescendants().OfType().Select(text => text.Text)); said.ShouldContain("key-0", Case.Insensitive, "the question has to name what is going"); said.ShouldContain("4 hosts authenticate with it"); said.ShouldContain("no undo"); }); } /// /// The rail is the only way to reach a category, so a button that lands on nothing walls off three /// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they /// are the size a pointer can find, which a zero-height row in a collapsed border would not be. /// [Fact] public async Task TheCategoryRailIsBigEnoughToClick() { await OnTheVaultAsync((screen, _) => { var buttons = screen.GetVisualDescendants() .OfType