Merge branch 'claude/sleepy-chebyshev-cda68d'

This commit is contained in:
2026-07-29 15:35:02 +02:00
10 changed files with 641 additions and 16 deletions
@@ -29,6 +29,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
private readonly FakeVaultServer server = new();
/// <remarks>
/// The real factory would need a reachable sshd, which <c>DodoSSH.Client.Ssh.Tests</c> covers against
/// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
/// the connect path reachable.
/// </remarks>
private readonly FakeSshConnectionFactory ssh = new();
private int signInAttempts;
private string directory = null!;
@@ -51,15 +58,26 @@ public sealed class ShellFlowTests : IAsyncLifetime
// In-memory assets rather than the application's Avalonia-resource provider, which reads the
// resource system at construction and needs an initialised toolkit. This is what
// ITerminalAssetProvider is for; nothing in this suite renders anything.
//
// The page carries the same two placeholders the real one does, because FakeRenderer attaches by
// reading them back out of the served page rather than by being handed the token.
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
{
["/terminal"] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
["/terminal"] = new(
"text/html; charset=utf-8",
System.Text.Encoding.UTF8.GetBytes(
$"<!doctype html><div data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></div>")),
}),
new SshNetConnectionFactory(knownHosts),
ssh,
TimeProvider.System);
// Started, as the application does immediately after composing it. Without the accept loop the
// page is never served, so nothing could attach a renderer.
workspace.Start();
shell = new MainWindowViewModel(
paths,
caches,
@@ -372,6 +390,95 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("bad day");
}
/// <remarks>
/// <para>
/// The page's own <c>term.focus()</c> focuses the textarea inside the document, which does nothing
/// while the window's keyboard focus is still on the Connect button — so the first keystrokes of a
/// session went to the shell's UI rather than the remote shell, and the terminal had to be clicked
/// first. The view hands the control focus when this fires; see <c>NativeKeyboardFocus</c> for why an
/// ordinary <c>Focus()</c> call is enough in that direction and not in the other.
/// </para>
/// <para>
/// What this covers is the plumbing that carries the fix: that the raise is on the success path and
/// happens once per session, and that the shell forwards it. Deleting the raise outright is already a
/// build error — the event would be unused, and warnings are errors — but moving it, which is the
/// likelier mistake, is not. It does <em>not</em> cover the focus call itself: that needs a native
/// window, and headless Avalonia has none, which is exactly why this class of defect has escaped
/// tests here before. Measured separately in a harness; see docs/platform-flags.md.
/// </para>
/// </remarks>
[Fact]
public async Task ConnectingAsksTheViewToFocusTheTerminal()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Connected", Case.Insensitive);
requests.ShouldBe(1);
// Again, on a second session. This is why it is an event and not a bound flag: a boolean that was
// already true would not move focus to the terminal the user just opened.
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(2);
}
/// <remarks>
/// Focus must not be taken on a failure. A host-key prompt needs the keyboard on the prompt's own
/// buttons, and taking it into a terminal that has no session would strand the decision.
/// </remarks>
[Fact]
public async Task AFailedConnect_DoesNotAskForTheTerminalToBeFocused()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeTrue();
requests.ShouldBe(0);
}
/// <remarks>
/// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile
/// and pass every other test, while leaving a discarded vault able to move focus in a locked window.
/// </remarks>
[Fact]
public async Task LockingStopsTheShellForwardingFocusRequests()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(1);
await shell.LockCommand.ExecuteAsync(null);
shell.Vault.ShouldBeNull();
// The discarded vault is detached, so even a late raise from it reaches nobody.
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(1);
}
[Fact]
public async Task AnInvalidHost_IsRefusedWithAReason()
{
@@ -569,4 +676,17 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.SaveHostCommand.ExecuteAsync(null);
}
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
private async Task<VaultViewModel> ReadyToConnectAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
return vault;
}
}