Merge branch 'claude/delete-confirmations-becf4a'
ci / build and test (push) Failing after 2s

This commit is contained in:
2026-07-31 11:52:27 +02:00
11 changed files with 979 additions and 48 deletions
@@ -1,5 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.App.Views;
@@ -223,6 +226,88 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
});
}
/// <remarks>
/// <para>
/// The strip along the sidebar's bottom edge with the question in it instead 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 — in a 268-pixel column whose middle is a list that has already taken every spare pixel.
/// </para>
/// <para>
/// Worth measuring rather than assuming, because this is the one card in the application a user cannot
/// scroll: the sidebar's only <c>ScrollViewer</c> is inside the host list, so a button pushed past the
/// bottom edge here would leave the question unanswerable in either direction.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithADeletionInQuestion()
{
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHost.IsConnected = true;
vault.DeleteHostCommand.Execute(null);
vault.IsConfirmingDeletion.ShouldBeTrue();
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <c>TryBuildAuthentication</c> 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.
/// </para>
/// </remarks>
[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 OnTheSidebarAsync((sidebar, window) =>
{
var row = sidebar.HostList.GetVisualDescendants()
.OfType<ListBoxItem>()
.First();
var centre = row.TranslatePoint(
new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the row 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 row selects it");
vault.Status.ShouldContain(
"not in this vault any more",
Case.Insensitive,
"the double-click has to reach the connect command");
});
}
// ---- The vault screen ----
[Fact]
@@ -283,6 +368,53 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
/// application.
/// </remarks>
[Fact]
public async Task TheVaultScreenFitsWithADeletionInQuestion()
{
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<ConfirmDeleteCard>().ShouldHaveSingleItem();
var said = string.Join(
" ",
card.GetVisualDescendants().OfType<TextBlock>().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");
});
}
/// <remarks>
/// 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
@@ -375,6 +507,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
/// column while the listing under it is still showing. A directory, because that is the longer of the
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
/// </remarks>
[Fact]
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
{
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
"2026-07-30",
"/srv/releases/site/backups/nightly/2026-07-30",
IsDirectory: true);
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
// ---- The chrome ----
/// <remarks>
+259 -10
View File
@@ -729,7 +729,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
await vault.DeleteHostCommand.ExecuteAsync(null);
await DeleteSelectedHostAsync(vault);
vault.Hosts.ShouldBeEmpty();
@@ -739,6 +739,219 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.PendingChanges.ShouldBe(0);
}
// ---- The question in front of a deletion ----
/// <remarks>
/// The half that makes the confirmation worth having: pressing DELETE has to change nothing at all. A
/// card that appeared after the item had already gone would be a receipt, not a question.
/// </remarks>
[Fact]
public async Task DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
vault.DeleteHostCommand.Execute(null);
var question = vault.PendingDeletion.ShouldNotBeNull();
question.Question.ShouldContain("prod-db", Case.Insensitive);
vault.IsConfirmingDeletion.ShouldBeTrue();
vault.ShowsHostActions.ShouldBeFalse("the buttons are what the question replaces");
vault.Hosts.ShouldHaveSingleItem();
server.LiveRowCount.ShouldBe(1);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Hosts.ShouldBeEmpty();
vault.PendingDeletion.ShouldBeNull("the question goes when it is answered");
vault.ShowsHostActions.ShouldBeTrue();
}
[Fact]
public async Task CancellingADeletion_LeavesTheItemWhereItWas()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "prod deploy");
vault.SelectedCredential = vault.Credentials[0];
vault.DeleteCredentialCommand.Execute(null);
vault.CancelDeleteCommand.Execute(null);
vault.PendingDeletion.ShouldBeNull();
// And the answer that would have deleted it has nothing left to act on.
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Credentials.ShouldHaveSingleItem();
server.LiveRowCount.ShouldBe(1);
}
/// <remarks>
/// What the question is for. A key that two hosts authenticate with is not the same deletion as one
/// nothing uses, and the hosts do not fall back to a typed password when it goes — they refuse, which is
/// asserted from the connect path's side in
/// <see cref="AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword"/>.
/// </remarks>
[Fact]
public async Task TheQuestionAboutAKey_CountsTheHostsThatAuthenticateWithIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
var keyId = vault.Keys[0].EntityId;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "prod-web");
await BindKeyAsync(vault, Host(vault, "prod-db"), keyId);
await BindKeyAsync(vault, Host(vault, "prod-web"), keyId);
vault.SelectedKey = vault.Keys[0];
vault.DeleteKeyCommand.Execute(null);
var question = vault.PendingDeletion.ShouldNotBeNull();
question.HasUsage.ShouldBeTrue();
question.Usage.ShouldContain("2 hosts");
question.Usage.ShouldContain("prod-db");
question.Usage.ShouldContain("prod-web");
// And the sentence above it says how far the deletion travels, which needs no host at all.
question.Consequence.ShouldContain("no undo", Case.Insensitive);
}
/// <remarks>
/// A key nothing uses gets no scare line, which is the other half of counting: a warning that appeared
/// every time would say nothing the second time.
/// </remarks>
[Fact]
public async Task TheQuestionAboutAKeyNothingUses_SaysNothingAboutHosts()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "spare");
await AddHostAsync(vault, "prod-db");
vault.SelectedKey = vault.Keys[0];
vault.DeleteKeyCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeFalse();
}
/// <remarks>
/// The failure this guards against is a question answered about something else: arm the deletion, click
/// another row, press the button that is still on screen. The armed item is what the answer acts on, and
/// choosing a different one takes the question away rather than re-aiming it.
/// </remarks>
[Fact]
public async Task ChoosingSomethingElse_TakesTheQuestionAway()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "prod deploy");
await AddCredentialAsync(vault, "staging deploy");
await vault.LoadAsync(Token);
vault.Section = VaultSection.Credentials;
vault.SelectedVaultItem = vault.VaultItems[0];
vault.DeleteCredentialCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull();
vault.SelectedVaultItem = vault.VaultItems[1];
vault.PendingDeletion.ShouldBeNull();
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Credentials.Count.ShouldBe(2, "nothing was agreed to");
}
/// <remarks>
/// The case the naive rule got wrong. A reload replaces every row object in the list, so disarming on
/// any change of the selected <em>row</em> would let the pass that runs every minute take the card away
/// from somebody halfway through reading it. The entity id is what the rule compares.
/// </remarks>
[Fact]
public async Task ASyncUnderneathAnArmedQuestion_LeavesItAlone()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
vault.DeleteHostCommand.Execute(null);
var armed = vault.PendingDeletion.ShouldNotBeNull();
await vault.SyncCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
vault.PendingDeletion.ShouldBe(armed);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Hosts.ShouldBeEmpty();
}
/// <remarks>
/// Opening an editor is the other way the pane the question is in stops being about the question: the
/// vault screen's Add buttons stay on screen beside the detail pane, so a password editor can open over
/// an armed deletion. It disarms rather than stacking two forms in a 244-pixel column.
/// </remarks>
[Fact]
public async Task OpeningAnEditor_TakesTheQuestionAway()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "prod deploy");
vault.SelectedCredential = vault.Credentials[0];
vault.DeleteCredentialCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull();
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue();
vault.PendingDeletion.ShouldBeNull();
}
/// <remarks>
/// An answer to a question about something that has since gone — the realistic way being a pass that
/// pulled somebody else's deletion. The reload that brings that news normally moves the selection and
/// takes the question with it; this holds the guard behind that, which is what keeps a stale agreement
/// from being a silent no-op under a card that has just been pressed.
/// </remarks>
[Fact]
public async Task AnsweringAboutSomethingAlreadyGone_SaysSo()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
vault.SelectedKey = vault.Keys[0];
vault.DeleteKeyCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull();
// Underneath the question, as another machine's deletion would arrive.
await vault.Session.SshKeys.DeleteAsync(
vault.Session.ActiveVaultId, vault.Keys[0].EntityId, Token);
await vault.LoadAsync(Token);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Status.ShouldContain("no longer here");
}
[Fact]
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
{
@@ -1005,7 +1218,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddKeyAsync(vault, "deploy");
vault.SelectedKey = vault.Keys[0];
await vault.DeleteKeyCommand.ExecuteAsync(null);
await DeleteSelectedKeyAsync(vault);
vault.Keys.ShouldBeEmpty();
server.LiveRowCount.ShouldBe(0);
@@ -1387,7 +1600,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
vault.SelectedKey = vault.Keys[0];
await vault.DeleteKeyCommand.ExecuteAsync(null);
await DeleteSelectedKeyAsync(vault);
vault.Keys.ShouldBeEmpty();
vault.SelectedHost = vault.Hosts[0];
@@ -1412,7 +1625,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await BindKeyAsync(vault, vault.Hosts[0], keyId);
vault.SelectedKey = vault.Keys[0];
await vault.DeleteKeyCommand.ExecuteAsync(null);
await DeleteSelectedKeyAsync(vault);
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
@@ -1541,7 +1754,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddCredentialAsync(vault, "prod deploy");
vault.SelectedCredential = vault.Credentials[0];
await vault.DeleteCredentialCommand.ExecuteAsync(null);
await DeleteSelectedCredentialAsync(vault);
vault.Credentials.ShouldBeEmpty();
server.LiveRowCount.ShouldBe(0);
@@ -1573,7 +1786,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.SelectedCredential.ShouldBeNull();
await vault.DeleteCredentialCommand.ExecuteAsync(null);
// Explicitly rather than through the helper: with nothing selected there is nothing to ask about,
// and the absence of a question is what proves the button found nothing to aim at.
vault.DeleteCredentialCommand.Execute(null);
vault.PendingDeletion.ShouldBeNull();
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Credentials.ShouldHaveSingleItem();
}
@@ -1594,7 +1812,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
// And a kind with nothing in it is left out rather than reported as zero.
vault.SelectedCredential = vault.Credentials[0];
await vault.DeleteCredentialCommand.ExecuteAsync(null);
await DeleteSelectedCredentialAsync(vault);
await vault.LoadAsync(Token);
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
@@ -1725,7 +1943,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
vault.SelectedCredential = vault.Credentials[0];
await vault.DeleteCredentialCommand.ExecuteAsync(null);
await DeleteSelectedCredentialAsync(vault);
vault.Credentials.ShouldBeEmpty();
vault.SelectedHost = vault.Hosts[0];
@@ -1747,7 +1965,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
vault.SelectedCredential = vault.Credentials[0];
await vault.DeleteCredentialCommand.ExecuteAsync(null);
await DeleteSelectedCredentialAsync(vault);
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
@@ -1900,7 +2118,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
vault.SelectedHost = vault.Hosts[0];
await vault.DeleteHostCommand.ExecuteAsync(null);
await DeleteSelectedHostAsync(vault);
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
"the pin outlives the host, and the list has to admit it");
@@ -2673,6 +2891,37 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.SaveKeyCommand.ExecuteAsync(null);
}
/// <summary>The host row with a given name, which the list orders by label rather than by age.</summary>
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
/// <remarks>
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
/// which is what <c>DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered</c> holds it to. Tests
/// about something else go through these three helpers, so the two-step is spelled out in one place
/// rather than in ten.
/// </remarks>
private static async Task DeleteSelectedHostAsync(VaultViewModel vault)
{
vault.DeleteHostCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
}
/// <inheritdoc cref="DeleteSelectedHostAsync" />
private static async Task DeleteSelectedKeyAsync(VaultViewModel vault)
{
vault.DeleteKeyCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
}
/// <inheritdoc cref="DeleteSelectedHostAsync" />
private static async Task DeleteSelectedCredentialAsync(VaultViewModel vault)
{
vault.DeleteCredentialCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
}
/// <summary>Points a host at a key through the editor, the way a user would.</summary>
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);