using System.Text;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Ssh.Tests;
///
/// The pump against a real shell, with the transport replaced by a recorder.
///
///
/// Sits between the pump's unit tests, which use a fake shell, and the full end-to-end test, which
/// adds the loopback socket. Its value is diagnostic: when output does not reach a renderer, this
/// says whether the pump and SSH.NET are producing anything at all.
///
[Collection(SshCollection.Name)]
public sealed class PumpOverRealSshTests(SshServerFixture fixture)
{
/// Trusts the container's host key, then connects. First contact is refused by design.
private async Task ConnectTrustedAsync()
{
var knownHosts = new InMemoryKnownHostStore();
var factory = new SshNetConnectionFactory(knownHosts);
var request = new SshConnectionRequest(
fixture.Host,
fixture.Port,
SshServerFixture.Username,
new SshPasswordCredential(SshServerFixture.Password));
var unknown = await Should.ThrowAsync(async () =>
await factory.ConnectAsync(request, TestContext.Current.CancellationToken));
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
return await factory.ConnectAsync(request, TestContext.Current.CancellationToken);
}
[Fact]
public async Task ThePump_ForwardsRealShellOutput()
{
await using var connection = await ConnectTrustedAsync();
var shell = await connection.OpenShellAsync(
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
var transport = new CountingTransport();
await using var pump = new TerminalSessionPump(
1,
shell,
transport,
TimeProvider.System,
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
transport.Pump = pump;
var run = pump.RunAsync(TestContext.Current.CancellationToken);
// Marker split so the PTY's echo of the command line does not satisfy the match.
await pump.WriteInputAsync(
Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"\n"),
TestContext.Current.CancellationToken);
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(20);
while (TimeProvider.System.GetUtcNow() < deadline
&& !transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal))
{
await Task.Delay(50, TestContext.Current.CancellationToken);
}
transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal).ShouldBeTrue(
$"BytesRead={pump.BytesRead}, FramesSent={pump.FramesSent}, "
+ $"OutputFrames={transport.OutputFrames}, Text=<{transport.Text}>");
await pump.DisposeAsync();
try
{
await run;
}
catch (OperationCanceledException)
{
// Expected: disposing the pump cancels its run.
}
}
/// Accumulates output and acknowledges it, as a keeping-up renderer would.
private sealed class CountingTransport : ITerminalTransport
{
private readonly StringBuilder text = new();
private readonly Lock gate = new();
internal TerminalSessionPump? Pump { get; set; }
internal int OutputFrames { get; private set; }
internal string Text
{
get
{
lock (gate)
{
return text.ToString();
}
}
}
public ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken)
{
if (!TerminalFrame.TryRead(frame.Span, out var opcode, out _, out var payload)
|| opcode != (byte)TerminalServerOpcode.Output)
{
return ValueTask.CompletedTask;
}
lock (gate)
{
OutputFrames++;
text.Append(Encoding.UTF8.GetString(payload));
}
Pump?.Acknowledge((uint)payload.Length);
return ValueTask.CompletedTask;
}
}
}