Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,21 @@ Pty.Net is a cross platform, .NET library providing idiomatic bindings for `fork

Pty.Net supports Linux, macOS, and Windows. On versions of windows older than 1809 the [winpty](https://github.com/rprichard/winpty) is used. For windows 1809+ this library ships a side-by-side copy of conhost.

# Contributing
pty bindings for .NET languages. This allows you to spawn processes connected to a pseudoterminal.

This is useful for:

- Writing a terminal emulator.
- Convincing programs that you are a terminal in order to get control sequences from their output.

## Supported Platforms

Pty.Net supports Windows, MacOS, and Linux and can run on the following .NET runtimes:

- .NET 4.7.2
- .NET Standard 2.0

## Contributing

This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
Expand Down
2 changes: 1 addition & 1 deletion azure-pipelines/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ steps:
command: test
arguments: --no-build -c $(BuildConfiguration) -v n /p:CollectCoverage=true
testRunTitle: netcoreapp2.2-$(Agent.JobName)
workingDirectory: src
workingDirectory: src
70 changes: 70 additions & 0 deletions src/Pty.Net.Tests/ExitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Pty.Net.Tests
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xunit;

public class ExitTests
{
[Fact]
public async Task SuccessfulExitTest()
{
var completionSource = new TaskCompletionSource<int>();

using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);
terminal.ProcessExited += (sender, e) =>
{
completionSource.SetResult(e.ExitCode);
};

await terminal.RunCommand("exit", Utilities.TimeoutToken);

var exitCode = await completionSource.Task.WithCancellation(Utilities.TimeoutToken);

Assert.Equal(0, exitCode);
Assert.Equal(0, terminal.ExitCode);
}

[Fact]
public async Task UnsuccessfulExitTest()
{
var completionSource = new TaskCompletionSource<int>();

using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);
terminal.ProcessExited += (sender, e) =>
{
completionSource.SetResult(e.ExitCode);
};

await terminal.RunCommand("exit 1", Utilities.TimeoutToken);

var exitCode = await completionSource.Task.WithCancellation(Utilities.TimeoutToken);

Assert.Equal(1, exitCode);
Assert.Equal(1, terminal.ExitCode);
}

[Fact]
public async Task ForceKillTest()
{
var completionSource = new TaskCompletionSource<int>();

using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);
terminal.ProcessExited += (sender, e) =>
{
completionSource.SetResult(e.ExitCode);
};

terminal.Kill();

await completionSource.Task.WithCancellation(Utilities.TimeoutToken);
}
}
}
9 changes: 9 additions & 0 deletions src/Pty.Net.Tests/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Text;
using Xunit;

[assembly: CollectionBehavior(DisableTestParallelization = true)]
120 changes: 11 additions & 109 deletions src/Pty.Net.Tests/PtyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,126 +16,28 @@ namespace Pty.Net.Tests

public class PtyTests
{
private static readonly int TestTimeoutMs = Debugger.IsAttached ? 300_000 : 5_000;

private CancellationToken TimeoutToken { get; } = new CancellationTokenSource(TestTimeoutMs).Token;

[Fact]
public async Task ConnectToTerminal()
{
const uint CtrlCExitCode = 0xC000013A;

var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
const string Data = "abc✓ЖЖЖ①Ⅻㄨㄩ 啊阿鼾齄丂丄狚狛狜狝﨨﨩ˊˋ˙– ⿻〇㐀㐁䶴䶵";

string app = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(Environment.SystemDirectory, "cmd.exe") : "sh";
var options = new PtyOptions
{
Name = "Custom terminal",
Cols = Data.Length + Environment.CurrentDirectory.Length + 50,
Rows = 25,
Cwd = Environment.CurrentDirectory,
App = app,
Environment = new Dictionary<string, string>()
{
{ "FOO", "bar" },
{ "Bazz", string.Empty },
},
};

IPtyConnection terminal = await PtyProvider.SpawnAsync(options, this.TimeoutToken);

var processExitedTcs = new TaskCompletionSource<uint>();
terminal.ProcessExited += (sender, e) => processExitedTcs.TrySetResult((uint)terminal.ExitCode);

string GetTerminalExitCode() =>
processExitedTcs.Task.IsCompleted ? $". Terminal process has exited with exit code {processExitedTcs.Task.GetAwaiter().GetResult()}." : string.Empty;

var firstOutput = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
var firstDataFound = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
var output = string.Empty;
var checkTerminalOutputAsync = Task.Run(async () =>
{
var buffer = new byte[4096];
var ansiRegex = new Regex(
@"[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-ntqry=><~]))");

while (!this.TimeoutToken.IsCancellationRequested && !processExitedTcs.Task.IsCompleted)
{
int count = await terminal.ReaderStream.ReadAsync(buffer, 0, buffer.Length, this.TimeoutToken);
if (count == 0)
{
break;
}

firstOutput.TrySetResult(null);

output += encoding.GetString(buffer, 0, count);
output = output.Replace("\r", string.Empty).Replace("\n", string.Empty);
output = ansiRegex.Replace(output, string.Empty);
using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);

var index = output.IndexOf(Data);
if (index >= 0)
{
firstDataFound.TrySetResult(null);
if (index <= output.Length - (2 * Data.Length)
&& output.IndexOf(Data, index + Data.Length) >= 0)
{
return true;
}
}
}
await terminal.RunCommand($"echo {Data}", Utilities.TimeoutToken);

firstOutput.TrySetCanceled();
firstDataFound.TrySetCanceled();
return false;
});

try
{
await firstOutput.Task;
}
catch (OperationCanceledException exception)
{
throw new InvalidOperationException(
$"Could not get any output from terminal{GetTerminalExitCode()}",
exception);
}

try
{
byte[] commandBuffer = encoding.GetBytes("echo " + Data);
await terminal.WriterStream.WriteAsync(commandBuffer, 0, commandBuffer.Length, this.TimeoutToken);
await terminal.WriterStream.FlushAsync();

await firstDataFound.Task;

await terminal.WriterStream.WriteAsync(new byte[] { 0x0D }, 0, 1, this.TimeoutToken); // Enter
await terminal.WriterStream.FlushAsync();

Assert.True(await checkTerminalOutputAsync);
}
catch (Exception exception)
{
throw new InvalidOperationException(
$"Could not get expected data from terminal.{GetTerminalExitCode()} Actual terminal output:\n{output}",
exception);
}
Assert.True(await Utilities.FindOutput(terminal.ReaderStream, Data));
}

terminal.Resize(40, 10);
[Fact]
public async Task OldConnectToTerminal()
{
const string Data = "abc✓ЖЖЖ①Ⅻㄨㄩ 啊阿鼾齄丂丄狚狛狜狝﨨﨩ˊˋ˙– ⿻〇㐀㐁䶴䶵";

terminal.Dispose();
using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);

using (this.TimeoutToken.Register(() => processExitedTcs.TrySetCanceled(this.TimeoutToken)))
{
uint exitCode = await processExitedTcs.Task;
Assert.True(
exitCode == CtrlCExitCode || // WinPty terminal exit code.
exitCode == 1 || // Pseudo Console exit code on Win 10.
exitCode == 0); // pty exit code on *nix.
}
var output = await Utilities.RunAndFind(terminal, $"echo {Data}", Data, Utilities.TimeoutToken);

Assert.True(terminal.WaitForExit(TestTimeoutMs));
Assert.NotNull(output);
}
}
}
79 changes: 79 additions & 0 deletions src/Pty.Net.Tests/ResizeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Pty.Net.Tests
{
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Xunit;

public class ResizeTests
{
[Fact]
public async Task ZeroSizeTest()
{
using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);

terminal.Resize(0, 0);
}

[Fact]
public async Task NegativeSizeTest()
{
using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);

Assert.Throws<ArgumentOutOfRangeException>(() => terminal.Resize(80, -25));
Assert.Throws<ArgumentOutOfRangeException>(() => terminal.Resize(-80, 25));
Assert.Throws<ArgumentOutOfRangeException>(() => terminal.Resize(-80, -25));
}

[Fact]
public async Task LargeInvalidSizeTest()
{
using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);

var size = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? short.MaxValue + 1
: ushort.MaxValue + 1;

Assert.Throws<OverflowException>(() => terminal.Resize(size, 25));
Assert.Throws<OverflowException>(() => terminal.Resize(80, size));
Assert.Throws<OverflowException>(() => terminal.Resize(size, size));
}

[Fact]
public async Task LargeValidSizeTest()
{
using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);

var size = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? (int)short.MaxValue
: (int)ushort.MaxValue;

terminal.Resize(size, 25);
terminal.Resize(80, size);
terminal.Resize(size, size);
}

[Fact]
public async Task ActuallyResizesTest()
{
using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken);
terminal.Resize(72, 13);

var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "mode"
: "echo Lines: && tput lines && echo Columns: && tput cols";

var output = await Utilities.RunAndFind(terminal, command, "Lines:\\D*(?<rows>\\d+).*Columns:\\D*(?<cols>\\d+)");

var metches = Regex.Match(output, "Lines:\\D*(?<rows>\\d+).*Columns:\\D*(?<cols>\\d+)");

Assert.Equal("13", metches.Groups["rows"].Value);
Assert.Equal("72", metches.Groups["cols"].Value);
}
}
}
Loading