using DodoSSH.Client.Ssh; namespace DodoSSH.Client.Terminal.Tests; /// /// What the workspace waits for outside the process, and how long a session lives once it has one. /// /// /// /// The renderer gate itself is not in question. drops frames when /// nothing is attached, so a session opened before the renderer arrives loses its SessionOpened frame /// and streams output at a terminal that was never created — and that it opens when a renderer does attach /// is covered by . What is worth a test here is the /// half that used to be missing: waiting for a renderer that never arrives has to end. /// /// /// The session count is here for a different reason. It is shown to a user on the unlock screen, as the /// disclosure that locking the vault leaves shells running, and a wrong number there is not a cosmetic bug: /// it either hides a live connection or invents one. /// /// public sealed class TerminalWorkspaceTests { /// How long polls before calling it a failure. private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10); [Fact] public async Task WaitingForARendererThatNeverAttaches_GivesUp() { // The shipped failure this stands in for is a WebView2 that never initialises — no Evergreen // runtime, an install blocked by policy, an AppContainer that cannot reach loopback. From this // side they are identical and all look like the listener being up with nothing ever connecting // to it. Before the wait was bounded this test would have hung instead of failing. await using var workspace = CreateWorkspace( new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMilliseconds(250)); workspace.Start(); await Should.ThrowAsync(async () => await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken)); } [Fact] public async Task WaitingForARenderer_ObeysItsCancellationToken() { // The timeout is the backstop; the caller's token is what makes a Connect the user gave up on // return at once rather than sitting out the rest of the wait. The timeout here is long enough // that only cancellation can end this. await using var workspace = CreateWorkspace( new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMinutes(5)); workspace.Start(); using var cancellation = new CancellationTokenSource(); var wait = workspace.WaitForRendererAsync(cancellation.Token); await cancellation.CancelAsync(); await Should.ThrowAsync(async () => await wait); } [Fact] public async Task AnOpenSessionIsReportedAsLive() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); workspace.LiveSessionCount.ShouldBe(0); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); workspace.LiveSessionCount.ShouldBe(1); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); workspace.LiveSessionCount.ShouldBe(2); } /// /// The case a naive sessions.Count gets wrong. Nothing removes the entry when the remote /// closes the channel by itself — the session is still in the dictionary, holding a connection that /// is finished — so counting entries would report a shell that exited as still running. A user /// deciding whether it is safe to walk away is the person that lie is told to. /// [Fact] public async Task ASessionWhoseRemoteHasExitedIsNotReportedAsLive() { // A shell with no output to give: its first read returns 0, which is a remote closing the // channel, so the pump finishes on its own with nobody asking it to. var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await WaitUntilAsync(() => workspace.LiveSessionCount == 0); } [Fact] public async Task ClosingASessionEndsItAndDisposesItsConnection() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await workspace.CloseSessionAsync(sessionId); workspace.LiveSessionCount.ShouldBe(0); connections.Connections.ShouldHaveSingleItem().IsDisposed.ShouldBeTrue(); } /// /// /// Per-session liveness, which the tab strip's status dot is. The aggregate count answers "may I walk /// away"; this answers "is this tab still connected", and a tab that went on claiming a shell /// it no longer has would be the same lie one row down. /// /// /// The unknown-id case is asserted because it is what a stale tab asks. A session id this workspace /// never issued, or has already closed, is not live — it must not throw and must not be optimistic. /// /// [Fact] public async Task LivenessIsAnsweredPerSession() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var first = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); var second = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); workspace.IsSessionLive(first).ShouldBeTrue(); workspace.IsSessionLive(second).ShouldBeTrue(); workspace.IsSessionLive(9999).ShouldBeFalse("this workspace never issued that id"); await workspace.CloseSessionAsync(first); workspace.IsSessionLive(first).ShouldBeFalse(); workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another"); } /// /// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the /// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user /// closed has a caller who already knows, and telling it would turn one close into two. /// [Fact] public async Task ASessionEndingOnItsOwnIsAnnounced() { // A shell with no output to give: its first read returns 0, which is a remote closing the channel, // so the pump finishes with nobody asking it to. var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); var ended = new List(); workspace.SessionEnded += (_, e) => { lock (ended) { ended.Add(e.SessionId); } }; var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await WaitUntilAsync(() => { lock (ended) { return ended.Contains(sessionId); } }); } /// [Fact] public async Task ClosingASessionIsNotAnnouncedBack() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var announcements = 0; workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await workspace.CloseSessionAsync(sessionId); Volatile.Read(ref announcements) .ShouldBe(0, "a close the caller asked for is not news to report back to it"); } /// /// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so /// that the SSH connections are known to be released rather than assumed to be. /// [Fact] public async Task DisposingTheWorkspaceClosesEverySession() { var connections = new FakeConnectionFactory(); var workspace = CreateWorkspace(connections); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await workspace.DisposeAsync(); workspace.LiveSessionCount.ShouldBe(0); connections.Connections.Count.ShouldBe(2); connections.Connections.ShouldAllBe(connection => connection.IsDisposed); } // ---- Helpers ---- /// /// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they /// take a fake anyway, because reaching a real host from here would make this a network test. /// /// The gate's bound, or null for the shipped default. private static TerminalWorkspace CreateWorkspace( ISshConnectionFactory connections, TimeSpan? rendererTimeout = null) => new( StubAssets(), connections, TimeProvider.System, rendererTimeout is { } timeout ? new TerminalWorkspaceOptions { RendererTimeout = timeout } : null); private static InMemoryTerminalAssetProvider StubAssets() => new(new Dictionary(StringComparer.Ordinal) { [TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", ""u8.ToArray()), }); private static SshConnectionRequest Request() => new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")); /// /// Polled rather than awaited on a task, because the point is what an observer of the property /// sees: the pump ends on a thread of its own, and the count has to catch up without anyone /// telling it to. /// private static async Task WaitUntilAsync(Func condition) { var deadline = TimeProvider.System.GetUtcNow() + PollTimeout; while (TimeProvider.System.GetUtcNow() < deadline) { if (condition()) { return; } await Task.Delay(20, TestContext.Current.CancellationToken); } throw new TimeoutException($"The condition was still false after {PollTimeout}."); } }