using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; 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; /// /// How the empty terminal's prompt answers a keyboard: where the caret goes, and what Enter, Tab and Escape do. /// /// /// What each key decides is ShellFlowTests' business; this is the half only a real control can show — /// that the caret is on the line that is asking, every time the line that is asking changes. A prompt whose /// password line appeared without the caret on it would take the password into the name it had just read. /// The fixture is the palette suite's, for the palette suite's reasons: a real shell over a real unlocked /// vault, and nothing that reaches a network. /// public sealed class ConnectPromptTests : IAsyncLifetime { private const string Passphrase = "a sufficiently long passphrase"; private const string ServerUrl = "https://dodossh.example"; /// Far below the shipped profile: nothing here attacks a wrap. 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 MainWindowViewModel shell = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() { caches = ClientCacheFactory.ForMemory($"prompt-{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(StringComparer.Ordinal)), Substitute.For(), TimeProvider.System); await knownHosts.OpenAsync(session, Token); vault = new VaultViewModel(session, workspace, knownHosts, static () => null); await SeedAsync(); shell = new MainWindowViewModel( ClientPaths.Default, caches, workspace, knownHosts, Substitute.For(), (_, _) => throw new NotSupportedException("nothing here signs in"), TimeProvider.System, // Never asked for a session: the palette searches the host list and connects through the // vault's own command, and nothing on this screen transfers a file. Substitute.For(), CheapProfile) { // The state the palette is only ever open in. Assigned rather than reached through the unlock // path, which would be a second enrollment and a second Argon2 pass for no extra coverage. State = ShellState.Unlocked, Vault = vault, }; } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync(); knownHosts.Close(); await workspace.DisposeAsync(); await session.DisposeAsync(); caches.Dispose(); } [Fact] public async Task ThePromptTakesTheKeyboardWhenItAppears() { await OnThePromptAsync((prompt, _) => prompt.TargetBox.IsFocused.ShouldBeTrue()); } /// /// Enter on an address moves the question to the password, and the caret with it — typing straight on is /// what somebody does next, and it has to land in the password box rather than on the end of the address. /// [Fact] public async Task EnterOnAnAddressPutsTheCaretOnThePassword() { await OnThePromptAsync((prompt, window) => { shell.PromptText = "root@10.0.0.5"; window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); Relayout(window); shell.IsPromptAskingForPassword.ShouldBeTrue(); prompt.PasswordBox.IsEffectivelyVisible.ShouldBeTrue(); prompt.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question"); }); } /// Escape off the password line puts the caret back on the name, which is still there. [Fact] public async Task EscapeFromThePasswordPutsTheCaretBackOnTheName() { await OnThePromptAsync((prompt, window) => { shell.PromptText = "root@10.0.0.5"; window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); Relayout(window); window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None); Relayout(window); shell.IsPromptAskingForPassword.ShouldBeFalse(); prompt.PasswordBox.IsEffectivelyVisible.ShouldBeFalse(); prompt.TargetBox.IsFocused.ShouldBeTrue(); shell.PromptText.ShouldBe("root@10.0.0.5"); }); } /// /// Tab completes rather than moving the focus away, when there is something to complete to — and leaves /// the caret at the end of what it wrote, so Enter is the next key rather than End. /// [Fact] public async Task TabCompletesTheNameAndKeepsTheCaretOnIt() { await OnThePromptAsync((prompt, window) => { shell.PromptText = "host-3"; shell.PromptMatches.ShouldNotBeEmpty(); shell.PromptText = "ost-3"; window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None); shell.PromptText.ShouldBe("host-3"); prompt.TargetBox.IsFocused.ShouldBeTrue("Tab completed rather than moving on"); prompt.TargetBox.CaretIndex.ShouldBe("host-3".Length); }); } // ---- Helpers ---- /// Shows the prompt in a window the size of the terminal's rectangle, and runs one body against it. private Task OnThePromptAsync(Action body) => LayoutHarness.OnTheUiThreadAsync( () => { shell.ShowTerminalCommand.Execute(null); shell.IsConnectPromptShowing.ShouldBeTrue("every case here starts on an empty terminal"); var prompt = new ConnectPrompt { DataContext = shell }; var window = new Window { Content = prompt }; LayoutHarness.Settle(window, 900, 600); // The posted focus the application's dispatcher would run once the prompt was laid out. Dispatcher.UIThread.RunJobs(); try { body(prompt, window); } finally { window.Close(); } }, Token); /// Runs the layout pass, and the focus it posts, that the application's dispatcher would. private static void Relayout(Window window) { Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); Dispatcher.UIThread.RunJobs(); } /// Enough hosts that a partial name has more than one to match. private async Task SeedAsync() { for (var i = 0; i < 6; i++) { vault.NewHostCommand.Execute(null); vault.EditorLabel = $"host-{i}"; vault.EditorHostname = $"host-{i}.internal"; vault.EditorUsername = "deploy"; await vault.SaveHostCommand.ExecuteAsync(null); } await vault.LoadAsync(Token); } }