Public Access
Restyle the drawer, pin folders on a host, and say when it was last connected
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Import;
|
||||
@@ -996,6 +997,126 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
IsReadOnly: false),
|
||||
isLive: false);
|
||||
|
||||
// ---- Last connected ----
|
||||
//
|
||||
// The card's ago-text: VaultViewModel.DescribeElapsed is the pure word-choice, RefreshLastConnectedAsync
|
||||
// is the log read, and the two triggers below — the hosts screen coming back on screen and a session
|
||||
// ending on its own — are decision 4's whole "when" in HANDOFF-hosts-v5.md.
|
||||
|
||||
/// <remarks>
|
||||
/// Every boundary the wording changes at: fifty-nine seconds is still "just now" and sixty is the first
|
||||
/// "1 min ago"; the same shape repeats crossing into hours and into days. A pure function of the gap, so
|
||||
/// none of this needs a fake clock — see the remark on <c>VaultViewModel.DescribeElapsed</c> itself.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(0, "just now")]
|
||||
[InlineData(59, "just now")]
|
||||
[InlineData(60, "1 min ago")]
|
||||
[InlineData(150, "2 min ago")]
|
||||
[InlineData(3599, "59 min ago")]
|
||||
[InlineData(3600, "1 hr ago")]
|
||||
[InlineData(7200, "2 hr ago")]
|
||||
[InlineData(86399, "23 hr ago")]
|
||||
[InlineData(86400, "1 day ago")]
|
||||
[InlineData(172800, "2 days ago")]
|
||||
public void DescribeElapsed_MatchesTheWordACardShouldShowAtEachBoundary(int seconds, string expected) =>
|
||||
VaultViewModel.DescribeElapsed(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
|
||||
|
||||
/// <remarks>
|
||||
/// The hosts screen's own activation — one of the two moments <c>VaultViewModel.RefreshLastConnectedAsync</c>
|
||||
/// is read on. <c>ReadyToConnectAsync</c> already visited this screen once, before the log held anything
|
||||
/// worth reading, so the log is seeded only afterwards and the screen is left and returned to — the
|
||||
/// transition <c>MainWindowViewModel.UpdateLastConnectedVisibility</c> actually keys off, rather than the
|
||||
/// level, which fired already.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ReturningToTheHostsScreen_FillsInLastConnectedFromTheLog()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
var host = vault.Hosts[0];
|
||||
|
||||
await vault.Session.ConnectionLog.CreateAsync(
|
||||
vault.Session.ActiveVaultId,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = host.Label,
|
||||
Address = host.Address,
|
||||
HostId = host.EntityId,
|
||||
StartedAt = TimeProvider.System.GetUtcNow().AddDays(-3),
|
||||
DeviceName = "a workstation",
|
||||
},
|
||||
Token);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||
|
||||
await EventuallyAsync(
|
||||
() => host.LastConnectedText.Length > 0,
|
||||
"activating the hosts screen should have read the log");
|
||||
|
||||
host.LastConnectedText.ShouldBe("3 days ago");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A host the log has never named. Empty rather than a dash or the word "never" — see
|
||||
/// <c>HostRowViewModel.LastConnectedText</c>'s own remarks: a host nobody has connected to and a host
|
||||
/// whose log simply has not been read yet look identical from this row, and neither is a claim it can
|
||||
/// make on its own.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostTheLogHasNeverNamed_ShowsNoAgoText()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
var host = vault.Hosts[0];
|
||||
|
||||
await vault.RefreshLastConnectedAsync(Token);
|
||||
|
||||
host.LastConnectedText.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Decision 4's second half: a host with a session open right now shows the green dot instead of an
|
||||
/// ago-text, even with an entry in the log to offer. Printing both would answer the same question twice,
|
||||
/// and the older answer is the one likeliest to be misread as current.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AConnectedHost_ShowsNoAgoTextEvenWithAnEntryInTheLog()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
var host = vault.Hosts[0];
|
||||
|
||||
await vault.Session.ConnectionLog.CreateAsync(
|
||||
vault.Session.ActiveVaultId,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = host.Label,
|
||||
Address = host.Address,
|
||||
HostId = host.EntityId,
|
||||
StartedAt = TimeProvider.System.GetUtcNow().AddMinutes(-5),
|
||||
DeviceName = "a workstation",
|
||||
},
|
||||
Token);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
host.IsConnected.ShouldBeTrue();
|
||||
|
||||
await vault.RefreshLastConnectedAsync(Token);
|
||||
|
||||
host.LastConnectedText.ShouldBeEmpty("the dot already says this host is open right now");
|
||||
}
|
||||
|
||||
// The other trigger — a session ending on its own — has no test here. It runs inside
|
||||
// MainWindowViewModel.OnWorkspaceSessionEnded's existing Dispatcher.UIThread.Post, the same one
|
||||
// RefreshConnectedHosts() already ran inside before this wave touched the method, and this suite has no
|
||||
// window pumping that dispatcher — see TransferQueueingTests's own remark on why it built a posted-action
|
||||
// queue rather than depend on Dispatcher.UIThread at all. A test posted there would time out proving
|
||||
// nothing about the one line this wave added, since the untestable half is wiring this wave did not
|
||||
// write. The call itself — `_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);`, placed
|
||||
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
|
||||
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
|
||||
|
||||
/// <remarks>
|
||||
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
||||
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
||||
@@ -2774,8 +2895,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
await AddGroupAsync(vault, "platform");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
|
||||
vault.GroupEditorDefaultPort = 2222;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
@@ -4267,17 +4387,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
|
||||
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
|
||||
/// machines through the editor is thirty rounds of open, pick, save.
|
||||
/// ◆ v5: dragging a host card onto a group card is gone, so this files through the editor instead — see
|
||||
/// <see cref="FileAsync"/> — which is the one thing every head can still do. What survives to measure is
|
||||
/// <see cref="VaultViewModel.Matches"/> and <see cref="VaultViewModel.RebuildVisibleHosts"/>'s own
|
||||
/// "one level of the tree" filtering, fed by <see cref="VaultViewModel.GroupFilter"/> directly now that
|
||||
/// nothing sets it through a command — see that property's own remarks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The card goes into the group and off the level it was dragged from</b>, which is the whole of what
|
||||
/// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was
|
||||
/// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection
|
||||
/// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all
|
||||
/// read that property, and none of them should be aimed at a card that has left the screen. See
|
||||
/// <c>VaultViewModel.Matches</c> and <c>RebuildVisibleHosts</c>.
|
||||
/// <b>The card goes into the group and off the level it was filed from</b>, which is the whole of what
|
||||
/// filing looks like on a grid that holds one level of the tree — the host is inside the card it was
|
||||
/// filed under now, and that is where it is drawn. It used to stay put and gain a chip.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
@@ -4290,13 +4409,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
var group = vault.Groups.Single().EntityId;
|
||||
var host = vault.Hosts.Single();
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
|
||||
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
|
||||
vault.SelectedHost.ShouldBeNull("nothing on screen is it any more");
|
||||
|
||||
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
|
||||
// tree flattened, so the host is still in it.
|
||||
@@ -4310,22 +4427,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Hosts.Single().HasGroup.ShouldBeTrue();
|
||||
|
||||
// Opening the group is where it went, and the way to it.
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
vault.GroupFilter = vault.Groups.Single();
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
// And back out again, which is what the host's own editor is for now that the drop has one target.
|
||||
vault.OpenGroupCommand.Execute(null);
|
||||
// And back out again.
|
||||
vault.GroupFilter = null;
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => choice.EntityId is null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
|
||||
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
|
||||
|
||||
// Coming out of a group is the direction that lands the host back on this level, so here the
|
||||
// selection does survive the move.
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -4355,11 +4472,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
|
||||
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
vault.GroupFilter = vault.Groups.Single();
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
|
||||
|
||||
vault.OpenGroupCommand.Execute(null);
|
||||
vault.GroupFilter = null;
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
|
||||
}
|
||||
@@ -4392,8 +4509,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
.ShouldBe("prod-db", "two levels down, and the box reaches it");
|
||||
|
||||
// And inside a group it is that group's subtree: estate holds production, which holds the host.
|
||||
vault.OpenGroupCommand.Execute(
|
||||
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
|
||||
vault.GroupFilter =
|
||||
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
@@ -4427,8 +4544,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
|
||||
/// inside a group and filed under none would vanish from the screen it was created on, which is the
|
||||
/// papercut that comes free with a grid holding one level — so the editor opens on the group the user
|
||||
/// is standing in, and the picker shows it before anything is saved.
|
||||
/// papercut that comes free with a grid holding one level — so the editor opens on the group
|
||||
/// <see cref="VaultViewModel.GroupTarget"/> names, and the picker shows it before anything is saved.
|
||||
/// <c>GroupFilter</c> is written directly rather than through the deleted <c>OpenGroupCommand</c> — see
|
||||
/// that property's own remarks.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
|
||||
@@ -4438,7 +4557,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
vault.GroupFilter = vault.Groups.Single();
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
@@ -4457,31 +4576,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A drop is a gesture on the list, not on the form. Rewriting the saved host while a half-typed edit of
|
||||
/// one is open would be a save nobody asked for, and one they could then not cancel.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAHostWhileTheEditorIsOpen_IsRefused()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorLabel = "half-typed";
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
||||
new HostGroupMove(vault.Hosts.Single(), vault.Groups.Single().EntityId));
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull("nothing was written");
|
||||
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
||||
vault.Status.ShouldContain("editing");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Deleting a group leaves the machines under it alone <em>and</em> stops them naming it. It used to do
|
||||
@@ -4505,8 +4599,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
var question = vault.PendingDeletion.ShouldNotBeNull();
|
||||
|
||||
@@ -4549,8 +4642,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
|
||||
vault.DeletionTakesTheHostsToo = true;
|
||||
@@ -4580,17 +4672,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "staging");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal)));
|
||||
vault.DeletionTakesTheHostsToo = true;
|
||||
vault.CancelDeleteCommand.Execute(null);
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "staging", StringComparison.Ordinal));
|
||||
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "staging", StringComparison.Ordinal)));
|
||||
|
||||
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
|
||||
}
|
||||
@@ -4615,8 +4703,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorLabel = "half-typed";
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.PendingDeletion.ShouldBeNull("the question was never put");
|
||||
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
||||
@@ -4676,8 +4763,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
|
||||
|
||||
@@ -4879,10 +4965,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupParentAsync(vault, "production", "estate");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
|
||||
|
||||
vault.GroupEditorParentChoices
|
||||
.Select(choice => choice.Label)
|
||||
@@ -5355,24 +5439,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The desktop's drag, once more than one card is ticked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dragging one host onto a group card has always been <c>MoveHostToGroup</c>; a set dragged onto one has
|
||||
/// to file all of it, because moving whichever card the pointer happened to be holding and leaving the
|
||||
/// other five where they are is a gesture that quietly does a fraction of what it looks like it does. It
|
||||
/// is the picker's write with the picker skipped — see <c>ConfirmRegroupChosenHosts</c>, which shares it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The refusal is the one <c>RefusesTheDrop</c> makes for a single card, made once for the set: a drop is
|
||||
/// a gesture on the grid, and rewriting a host under a half-typed edit of it is a save nobody asked for
|
||||
/// and could not then cancel.
|
||||
/// ◆ v5: the drag this used to cover — a ticked set dropped straight onto a group card — left with the
|
||||
/// cards, and <c>FileChosenHostsUnderCommand</c> went with it; <c>ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce</c>
|
||||
/// covers what a set files to now that the group picker is the only route. What survives here is the
|
||||
/// guard: a drop was a gesture on the grid, refused under a half-typed edit because rewriting a host
|
||||
/// underneath one was a save nobody asked for and could not then cancel. The picker inherits the same
|
||||
/// refusal at the point it is raised instead — see <see cref="VaultViewModel.RegroupChosenHosts"/> —
|
||||
/// rather than at the point it is answered, since a picker cannot be dropped onto something mid-edit the
|
||||
/// way a card once was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem()
|
||||
public async Task RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -5385,25 +5465,29 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
||||
|
||||
var card = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
|
||||
vault.RegroupChosenHostsCommand.Execute(null);
|
||||
|
||||
Host(vault, "prod-db").Host.GroupId.ShouldBeNull("nothing is written under an open editor");
|
||||
vault.Status.ShouldNotBeEmpty("and it says which editor is in the way");
|
||||
vault.IsRegroupingChosenHosts.ShouldBeFalse("nothing is filed under an open editor");
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
|
||||
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
|
||||
vault.RegroupChosenHostsCommand.Execute(null);
|
||||
|
||||
Host(vault, "prod-db").Host.GroupId.ShouldBe(card.EntityId, vault.Status);
|
||||
Host(vault, "prod-web").Host.GroupId.ShouldBe(card.EntityId);
|
||||
vault.IsRegroupingChosenHosts.ShouldBeTrue("the editor is out of the way now");
|
||||
|
||||
var group = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
|
||||
.Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
|
||||
Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
|
||||
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -5555,12 +5639,119 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.Transfers.Status.ShouldContain("password");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
|
||||
// ---- The terminal pin strip ----
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
|
||||
/// the connection already open and the tab it opened already selected.
|
||||
/// </summary>
|
||||
private async Task<VaultViewModel> ConnectedHostWithAPinAsync(string path = "/var/www/app")
|
||||
{
|
||||
// The phone's only route into a group editor: it draws no groups panel, and a heading's own
|
||||
// selection bounces back to the host on purpose. The command has to work off the heading it was
|
||||
// pressed on rather than off SelectedGroup, or pressing one heading would edit another.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewPin = path;
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
|
||||
{
|
||||
await ConnectedHostWithAPinAsync();
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeTrue();
|
||||
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
// Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
|
||||
// that happens to be selected on the hosts screen.
|
||||
shell.ShowsPinStrip.ShouldBeFalse();
|
||||
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
|
||||
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
|
||||
{
|
||||
await ConnectedHostWithAPinAsync();
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeTrue();
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
|
||||
|
||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
|
||||
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
|
||||
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
|
||||
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
|
||||
/// entry independent of Requests.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClickingAPinChip_OpensFilesAtThatPath()
|
||||
{
|
||||
var vault = await ConnectedHostWithAPinAsync();
|
||||
|
||||
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
||||
|
||||
shell.IsTransfersShowing.ShouldBeTrue();
|
||||
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||
shell.Transfers.IsConnected.ShouldBeTrue();
|
||||
shell.Transfers.RemotePath.ShouldBe("/var/www/app");
|
||||
|
||||
ssh.SftpRequests.ShouldHaveSingleItem();
|
||||
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
|
||||
{
|
||||
// Both heads' only route into a group editor since v5: neither draws a group card to select one
|
||||
// from any more, so the command has to work off the heading it was pressed on. Two groups exist here
|
||||
// so a bug reading the wrong one would show up as the wrong label rather than passing by accident.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
@@ -5569,23 +5760,19 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupFromHeadingCommand.Execute(heading);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue();
|
||||
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
|
||||
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not some other group");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The heading hands its group to the editor rather than selecting it first, and this is why. A group
|
||||
/// selection clears the host selection — the desktop's two grids share one mark — and the phone draws no
|
||||
/// group cards at all, so selecting one here would take the highlight off the machine in the list with
|
||||
/// nothing on screen to say where it had gone, or how to get it back.
|
||||
/// The heading hands its group straight to the editor rather than selecting a card first, and this is
|
||||
/// why: through v4 a group selection cleared the host selection, since the desktop's two grids shared one
|
||||
/// mark, and neither head has drawn a group card to select since v5 — see <c>EditGroup</c>'s own remarks.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
|
||||
@@ -5920,6 +6107,226 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was");
|
||||
}
|
||||
|
||||
// ---- Pinned paths (QUICK ACCESS) ----
|
||||
|
||||
[Fact]
|
||||
public async Task APinAddedThroughTheEditor_LandsOnTheHostOnSave()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin.ShouldBeEmpty("the box empties so a second one can be typed straight away");
|
||||
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/var/www/app"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PinsAddedInOrder_KeepThatOrderOnTheHost()
|
||||
{
|
||||
// Order is the whole feature — see PinnedPathList's own remarks — so the editor's staging list has
|
||||
// to preserve it as faithfully as the domain type it is about to become.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
foreach (var path in new[] { "/var/www/app", "/etc/nginx", "/var/log/pm2" })
|
||||
{
|
||||
vault.EditorNewPin = path;
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
}
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").Host.PinnedPaths
|
||||
.ShouldBe(["/var/www/app", "/etc/nginx", "/var/log/pm2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinRemovedInTheEditor_IsGoneFromTheHostOnSave()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
vault.EditorNewPin = "/etc/nginx";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.RemoveEditorPinCommand.Execute("/var/www/app");
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBe(["/etc/nginx"]);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/etc/nginx"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellingAHostEdit_DropsThePinningEntirely()
|
||||
{
|
||||
// Unlike a tag, a pin has no id and nowhere else to live — so cancelling loses it outright rather
|
||||
// than leaving it behind for next time, which is what CancellingAHostEdit_DropsTheTaggingAndKeepsThe
|
||||
// Tag holds a tag's own name to.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
|
||||
Host(vault, "prod-db").Host.PinnedPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReopeningAPinnedHostsEditor_StagesItsExistingPins()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewHostsEditor_OpensWithNoPinsStaged()
|
||||
{
|
||||
// NewHostCommand has to clear whatever the previous host's edit left in EditorPinnedPaths — the same
|
||||
// reason every other editor field is reset there.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ABlankPin_IsRefusedAtTheAddBox()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = " ";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||
vault.Status.ShouldContain("blank");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinAlreadyStaged_IsRefusedRatherThanRepeated()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldHaveSingleItem();
|
||||
vault.Status.ShouldContain("already pinned");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnOverLongPin_IsRefusedAtTheAddBoxBeforeSave()
|
||||
{
|
||||
// The add affordance has to catch this itself rather than letting it ride to TryValidate: a refusal
|
||||
// that waits for SAVE throws away every other field typed on the form since, where one at the box
|
||||
// that caused it costs nothing else.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = new string('a', HostSecret.MaxPinnedPathLength + 1);
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||
vault.Status.ShouldContain(HostSecret.MaxPinnedPathLength.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AThirtyThirdPin_IsRefusedAtTheAddBox()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
for (var i = 0; i < HostSecret.MaxPinnedPaths; i++)
|
||||
{
|
||||
vault.EditorNewPin = $"/pin/{i}";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
}
|
||||
|
||||
vault.EditorPinnedPaths.Count.ShouldBe(HostSecret.MaxPinnedPaths);
|
||||
|
||||
vault.EditorNewPin = "/one/too/many";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.Count.ShouldBe(
|
||||
HostSecret.MaxPinnedPaths, "the add box refused the 33rd rather than staging it");
|
||||
vault.Status.ShouldContain(HostSecret.MaxPinnedPaths.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore()
|
||||
{
|
||||
@@ -6014,10 +6421,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
string? username = null,
|
||||
string? key = null)
|
||||
{
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
|
||||
|
||||
vault.GroupEditorDefaultPort = port;
|
||||
vault.GroupEditorDefaultUsername = username ?? string.Empty;
|
||||
@@ -6033,10 +6438,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
|
||||
{
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
|
||||
|
||||
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
||||
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
|
||||
|
||||
Reference in New Issue
Block a user