Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
T
jaap-jan be012585b3 Let a group be right-clicked, as a host already can
The host cards have had a menu since the grid replaced the sidebar; the group
cards above them had a double-click and two buttons beside the heading, and
nothing that named the card under the pointer. Open, Edit and Delete are on them
now, drawn and aimed the same way.

On the list rather than in the item template, for the reason the host grid's is:
the three commands are the vault's, and a ContextMenu inside a DataTemplate has
the row for its data context, so every binding in it would silently resolve to
nothing. The code-behind selects whatever was right-clicked before the menu
opens, and that is what makes one menu act on the card under the pointer rather
than on whichever was selected before.

**Cancelled over the space around the cards, and here that guard is doing more
than the host grid's.** GroupTarget falls back to the group whose contents are on
screen when no card is selected — the right answer for a pair of buttons beside
the heading, which would otherwise have no subject the moment a group with
nothing inside it is opened, and the wrong one for a menu that opened on a card.
Without the guard, right-clicking the gap beside the cards would offer to delete
the group the trail ends with: a question about something the user is not
pointing at, in the one menu where the answer is a deletion.

Only Open takes a parameter, and it has to. OpenGroupCommand's null is a real
argument rather than a missing one — it is the trail's first crumb, ALL HOSTS —
so an entry with no parameter would not open the card, it would leave the group
the user right-clicked and go back to the top level.

Two tests beside the two the host menu already had. What they hold that a build
cannot is the CommandParameter binding: a path that resolves to nothing compiles
and draws, and the entry would then quietly do the opposite of what it says. The
popup itself is still the platform's, so manual-checks 7.9 gained the group half
of the same check.
2026-08-04 16:27:01 +02:00

614 lines
27 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
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.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the grid of host cards answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
/// <c>HostsScreen.axaml.cs</c>.
/// </para>
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because the commands
/// on both of that screen's menus read the vault's selection. A menu that quietly acted on whichever host
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
/// suite — and the group cards have the same menu with a fallback behind it that makes getting it wrong
/// quieter still.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
/// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own
/// hosts and groups.
/// </para>
/// </remarks>
public sealed class HostGridTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private readonly VaultKnownHostStore knownHosts = new();
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultSession session = null!;
private VaultViewModel vault = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"sidebar-{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!;
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
await SeedAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
knownHosts.Close();
await workspace.DisposeAsync();
await vault.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// The rule the menu depends on. Without it the three commands would act on whatever was selected
/// before, which for Delete is a question asked about one machine and answered about another.
/// </remarks>
[Fact]
public async Task ARightClickSelectsTheHostUnderThePointer()
{
await OnTheGridAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
vault.SelectedHost = first;
RightClick(CardFor(screen, other), window);
vault.SelectedHost.ShouldBeSameAs(other);
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
// The commands are the vault's, which is the other half of putting the menu on the list rather
// than in the item template: a menu inside the template would have the row for its data context,
// and every one of these would silently bind to nothing.
var edit = menu.Items.OfType<MenuItem>().Single(item => item.Header is "Edit…");
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
edit.Command!.Execute(null);
vault.IsEditing.ShouldBeTrue();
vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before");
});
}
/// <remarks>
/// <para>
/// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
/// Delete over it would be three entries acting on whichever machine happened to be selected — which is
/// the whole mistake this handler exists to prevent, reached by clicking nothing at all.
/// </para>
/// <para>
/// Raised on the list itself rather than clicked at a point known to be empty. What the handler reads is
/// the event's source, and a source that is the list rather than an item is exactly what the empty space
/// produces; a coordinate would additionally be asserting where the wrap put the cards.
/// </para>
/// </remarks>
[Fact]
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
{
await OnTheGridAsync((screen, _) =>
{
var selected = Row(vault, "prod-db");
vault.SelectedHost = selected;
screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
{
RoutedEvent = Control.ContextRequestedEvent,
Source = screen.HostGrid,
});
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
/// <summary>
/// The same rule on the cards above, where getting it wrong is quieter and worse.
/// </summary>
/// <remarks>
/// <para>
/// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected — the
/// right answer for the pair of buttons beside the heading, and the wrong one for a menu that opened on a
/// card, which would then offer to delete a group the pointer is nowhere near.
/// </para>
/// <para>
/// Open is the one entry that takes a parameter, because <c>OpenGroupCommand</c>'s null is a real
/// argument — it is ALL HOSTS. That makes its <c>CommandParameter</c> binding the half most likely to
/// rot: a path that resolves to nothing compiles, draws, and quietly leaves the grid at the top level.
/// </para>
/// </remarks>
[Fact]
public async Task ARightClickSelectsTheGroupUnderThePointer()
{
await AddGroupAsync("staging");
await OnTheGridAsync((screen, window) =>
{
var first = GroupRow(vault, "production");
var other = GroupRow(vault, "staging");
vault.SelectedGroup = first;
RightClick(CardFor(screen, other), window);
vault.SelectedGroup.ShouldBeSameAs(other);
var menu = screen.GroupGrid.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
var items = menu.Items.OfType<MenuItem>().ToList();
var open = items.Single(item => item.Header is "Open");
open.Command.ShouldBeSameAs(vault.OpenGroupCommand);
open.CommandParameter.ShouldBeSameAs(other, "the card under the pointer, not ALL HOSTS");
var edit = items.Single(item => item.Header is "Edit…");
edit.Command.ShouldBeSameAs(vault.EditGroupCommand);
edit.Command!.Execute(null);
vault.IsEditingGroup.ShouldBeTrue();
vault.GroupEditorLabel.ShouldBe(
other.Label, "the card that was right-clicked, not the one selected before");
});
}
/// <remarks>
/// The space around the group cards, where a menu would be at its most misleading: nothing is under the
/// pointer, so an unguarded one would open against the fallback and offer Delete about the group the
/// trail ends with — which, once it is open, is not a card on screen at all.
/// </remarks>
[Fact]
public async Task ARightClickOffAnyGroupCardOpensNothingAndMovesNothing()
{
await OnTheGridAsync((screen, _) =>
{
var selected = GroupRow(vault, "production");
vault.SelectedGroup = selected;
screen.GroupGrid.RaiseEvent(new ContextRequestedEventArgs
{
RoutedEvent = Control.ContextRequestedEvent,
Source = screen.GroupGrid,
});
vault.SelectedGroup.ShouldBeSameAs(selected, "the selection the menu would have acted on");
screen.GroupGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
/// <summary>
/// A host held over a group card would be filed there, and one held over another host card would not.
/// </summary>
/// <remarks>
/// <para>
/// The group headings that used to sit between the cards are gone — see
/// <c>ScreenLayoutTests.TheHostsGridHoldsCardsAndNoGroupHeadings</c> — and with them went the thing a
/// dragged host was dropped onto. This holds the replacement in place, and the refusal with it: a card
/// dropped onto another card used to file it beside that one, which was legible while a heading said
/// which group the band of cards belonged to and is guesswork now that none does.
/// </para>
/// <para>
/// What is <em>not</em> here is the platform's half — picking the card up, the cursor, the drop itself.
/// Headless Avalonia has no native window and can synthesise none of it. The write at the end of the
/// gesture is <c>ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected</c>, and what neither
/// covers is in docs/manual-checks.md 7.6.
/// </para>
/// </remarks>
[Fact]
public async Task TheGroupCardsAreWhatAcceptsADroppedHost()
{
await OnTheGridAsync((screen, _) =>
{
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, Row(vault, "prod-db")));
var onto = screen.GroupGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Single(item => item.DataContext is HostGroupRowViewModel);
var over = Over(onto, carried);
onto.Classes.ShouldContain("droptarget", "the card says it would take the host");
over.DragEffects.ShouldBe(DragDropEffects.Move);
var refused = Over(CardFor(screen, Row(vault, "stage-web")), carried);
refused.Handled.ShouldBeTrue("the screen answered rather than leaving it to the platform");
refused.DragEffects.ShouldBe(
DragDropEffects.None,
"a card dropped onto another card would be filed somewhere nothing on screen names");
// And the group card it was over a moment ago stops offering to take it, which is the half of
// this that is wrong far more often than the mark appearing at all.
onto.Classes.ShouldNotContain("droptarget");
});
}
/// <remarks>
/// <para>
/// The rule one press was split into two gestures for. Selecting a group aims its EDIT and DELETE at it
/// and does nothing else; opening one is what narrows the grid, and the trail is the way back out of it.
/// While a single press meant both, a group could not be named without every host outside it leaving the
/// screen at the same moment.
/// </para>
/// <para>
/// Driven through the properties the cards bind rather than through a click, because what is worth
/// holding here is the rule; the pointer is put on the gesture itself in the test below. A click would
/// otherwise be testing Avalonia's <c>SelectedItem</c> binding, which is not this application's code.
/// </para>
/// </remarks>
[Fact]
public async Task SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
{
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
// Re-found after the move, because the reload it ends with replaces every row in the list.
var production = vault.VisibleGroups.Single();
vault.SelectedGroup = production;
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group");
vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on");
vault.ShowsGroupActions.ShouldBeTrue();
vault.OpenGroupCommand.Execute(production);
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["prod-db"], "only what is filed under the group that is open");
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more");
vault.GroupTarget.ShouldBeSameAs(
production, "so the buttons fall back to the group whose contents are showing");
// Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the
// same job and went with the change.
vault.OpenGroupCommand.Execute(vault.GroupTrail[0].Group);
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain");
vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
}
/// <remarks>
/// The gesture, performed. It 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 — and the first half of
/// what it asserts is the half that would go unnoticed: one press must still only select, or the split
/// bought nothing.
/// </remarks>
[Fact]
public async Task DoubleClickingAGroupCardOpensIt()
{
// One press, in a window of its own, because a second pair of clicks in the same one is the gesture
// this is separating that press from.
await OnTheGridAsync((screen, window) =>
{
var centre = Centre(GroupCard(screen), window);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
vault.SelectedGroup.ShouldNotBeNull("one press selects the card");
vault.GroupFilter.ShouldBeNull("and opens nothing");
});
await OnTheGridAsync((screen, window) =>
{
var centre = Centre(GroupCard(screen), window);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
vault.GroupFilter.ShouldNotBeNull().Label.ShouldBe("production");
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
// And out again through the trail as it is actually drawn, rather than through the command. A
// crumb is an item in a template and the command it presses is the vault's, so the two are
// joined by a $parent binding — which is a string that compiles whether or not it resolves, and
// would leave a trail of buttons that do nothing.
var back = screen.GetVisualDescendants()
.OfType<Button>()
.First(button => button.DataContext is GroupCrumbViewModel { Group: null });
back.Command.ShouldNotBeNull("the crumb reached the vault's command").Execute(back.CommandParameter);
vault.GroupFilter.ShouldBeNull("ALL HOSTS is the way back out");
vault.VisibleHosts.Count.ShouldBe(2);
});
}
/// <summary>
/// The cards are one level of the tree, and the trail is how that level was reached.
/// </summary>
/// <remarks>
/// The grid used to draw every group at once, which was the only honest thing to do while a card was a
/// filter: a filter nobody can see is a filter nobody can turn off. Once opening a group became
/// navigation the cards became its contents — and a level with no name and no way back is a grid that
/// has quietly hidden things, which is what the trail is for.
/// </remarks>
[Fact]
public async Task OpeningANestedGroupShowsWhatIsInsideItAndTheWayBack()
{
await AddGroupAsync("estate");
await FileGroupUnderAsync("production", "estate");
vault.VisibleGroups.Select(row => row.Label)
.ShouldBe(["estate"], "the outermost groups, and production is not one of them any more");
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"], "what is inside estate");
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
vault.HasVisibleGroups.ShouldBeFalse("production has nothing inside it, so the cards fold away");
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate", "production"]);
// A middle crumb goes back one level rather than all the way out, which is the whole reason the
// trail is a row of buttons instead of a sentence saying where you are.
vault.OpenGroupCommand.Execute(vault.GroupTrail[1].Group);
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"]);
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
}
/// <summary>
/// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels.
/// </summary>
/// <remarks>
/// <para>
/// The two halves are one rule and are asserted together, because either alone would pass on a broken
/// version: a drawer that never opens satisfies the first, and one that opens on selection satisfies the
/// second. What is being held is that opening is <em>deliberate</em>.
/// </para>
/// <para>
/// Driven through the card's own button rather than by executing the command, since the thing most
/// likely to rot is the binding that reaches out of the item template to the vault's command — a
/// <c>$parent[ListBox]</c> path that resolves to nothing compiles, draws, and does nothing when pressed.
/// </para>
/// </remarks>
[Fact]
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
{
await OnTheGridAsync((screen, _) =>
{
var host = Row(vault, "stage-web");
vault.SelectedHost = host;
vault.IsDrawerOpen.ShouldBeFalse("selecting a card is not asking for the pane");
// The button is hidden until the pointer is on the card, so a click cannot be synthesised at a
// point: what a headless run can reach is the control and the command behind it.
var pencil = CardFor(screen, host)
.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("rowedit"));
pencil.Command.ShouldNotBeNull("the template's binding to the vault's command has to resolve");
pencil.Command.Execute(pencil.CommandParameter);
vault.IsDrawerOpen.ShouldBeTrue();
vault.IsShowingHostDetail.ShouldBeTrue("the pane, not one of the two editors");
vault.SelectedHost.ShouldBeSameAs(host, "the card the pencil was on");
});
}
/// <remarks>
/// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
/// selection that goes away entirely has to take it with it. Without that the flag would survive a
/// filter matching nothing, and the drawer would spring open again on the next card merely selected,
/// which is the behaviour the pencil exists to remove.
/// </remarks>
[Fact]
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
{
await OnTheGridAsync((_, _) =>
{
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
vault.IsDrawerOpen.ShouldBeTrue();
vault.SelectedHost = null;
vault.IsDrawerOpen.ShouldBeFalse();
vault.SelectedHost = Row(vault, "stage-web");
vault.IsDrawerOpen.ShouldBeFalse("the pane has to be asked for again");
});
}
// ---- Helpers ----
/// <summary>The same in-process format the screen's own drag carries.</summary>
/// <remarks>
/// Declared again here rather than made visible, because what the two have in common is the contract —
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
/// started carrying something else under it.
/// </remarks>
private static readonly DataFormat<HostRowViewModel> HostFormat =
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
/// <remarks>
/// The nearest a headless test gets to the gesture. No platform drag can be synthesised — there is no
/// native window to start one — but <c>DragOver</c> is an ordinary routed event, and it is where every
/// decision this screen makes about a drop is taken: whether the thing under the pointer would accept
/// the host, and whether it is marked while it is being held there. The drop itself only repeats that
/// question and runs the command. See docs/manual-checks.md 7.6 for what is left over.
/// </remarks>
private static DragEventArgs Over(Interactive target, DataTransfer carried)
{
var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);
target.RaiseEvent(over);
return over;
}
private static void RightClick(Visual row, Visual window)
{
var at = Centre(row, window);
((Window)window).MouseDown(at, MouseButton.Right);
((Window)window).MouseUp(at, MouseButton.Right);
}
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new HostsScreen { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
body(screen, window);
}
finally
{
window.Close();
}
},
Token);
private static ListBoxItem GroupCard(HostsScreen screen) =>
screen.GroupGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Single(item => item.DataContext is HostGroupRowViewModel);
/// <remarks>Any row: a host card or a group card, which are both items of a list on this screen.</remarks>
private static ListBoxItem CardFor(Visual screen, object row) =>
screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, row));
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <remarks>
/// Out of the cards on screen rather than out of every group, because that is what the card's own data
/// context is — <c>Groups</c> holds the same row objects, but only one level of them is drawn.
/// </remarks>
private static HostGroupRowViewModel GroupRow(VaultViewModel vault, string label) =>
vault.VisibleGroups.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
private async Task AddGroupAsync(string label)
{
vault.GroupEditorLabel = label;
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <summary>Files one group under another the way a user can: through the group's own editor.</summary>
private async Task FileGroupUnderAsync(string group, string parent)
{
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
private async Task SeedAsync()
{
foreach (var label in new[] { "prod-db", "stage-web" })
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
}
}