using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
///
/// How long a terminal session lives, and what the workspace says about it.
///
///
/// The count these tests are about is shown to a user on the unlock screen, as the disclosure that
/// locking the vault leaves shells running. A wrong number there is not a cosmetic bug: it either hides
/// a live connection or invents one.
///
public sealed class TerminalWorkspaceTests
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
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"));
[Fact]
public async Task AnOpenSessionIsReportedAsLive()
{
var connections = new FakeConnectionFactory();
await using var workspace = new TerminalWorkspace(
StubAssets(), connections, TimeProvider.System);
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 = new TerminalWorkspace(
StubAssets(), connections, TimeProvider.System);
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 = new TerminalWorkspace(
StubAssets(), connections, TimeProvider.System);
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 = new TerminalWorkspace(StubAssets(), connections, TimeProvider.System);
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);
}
///
/// 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() + Timeout;
while (TimeProvider.System.GetUtcNow() < deadline)
{
if (condition())
{
return;
}
await Task.Delay(20, TestContext.Current.CancellationToken);
}
throw new TimeoutException($"The condition was still false after {Timeout}.");
}
}