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();
}
///
/// Disposal is the one path that does close 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}.");
}
}