Public Access
Merge branch 'claude/sleepy-chebyshev-cda68d'
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user