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
25 changes: 25 additions & 0 deletions DevProxy.Integration.Tests/MockStdioResponsePluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ public async Task Nth_EffectiveMatcher_SharesFallbackCount(string? bodyRegex, st
}
}

[Fact]
public async Task StdinBodyIdPlaceholder_NewlineTerminatedJsonRpcMessage_IsReplaced()
{
var config = PluginConfig.FromJson("""
{
"mocks": [
{
"request": { "bodyFragment": "accept_eula" },
"response": { "stdout": "{\"jsonrpc\":\"2.0\",\"id\":@stdin.body.id,\"result\":{}}\n" }
}
]
}
""");
using var httpClient = new HttpClient();
using var plugin = new MockStdioResponsePlugin(
httpClient, NullLogger<MockStdioResponsePlugin>.Instance, new HashSet<UrlToWatch>(),
new TestProxyConfiguration(), config);

var request = CreateRequest("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"accept_eula\"}}\n");
await plugin.BeforeStdinAsync(request, CancellationToken.None);
Comment thread
waldekmastykarz marked this conversation as resolved.
Resolved

Assert.True(request.ResponseState.HasBeenSet);
Assert.Equal("{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}\n", request.StdoutResponse);
}

private static StdioRequestArgs CreateRequest(string body) => new()
{
Body = Encoding.UTF8.GetBytes(body),
Expand Down
156 changes: 156 additions & 0 deletions DevProxy.Integration.Tests/ProcessSmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,133 @@ public async Task Host_LoadsMockPluginFromConfig_AndServesMock()
}
}

[Fact]
public async Task Stdio_BackToBackJsonRpcMessages_AreMockedIndividually()
{
var hostDll = LocateHostDll();
Assert.True(File.Exists(hostDll), $"DevProxy host not built at {hostDll}. Run `dotnet build DevProxy` first.");

var workDir = Directory.CreateTempSubdirectory("devproxy-stdio-");
try
{
await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "stdio-mocks.json"), StdioMocksFile);
await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "devproxyrc.json"), StdioConfigFile);

var psi = new ProcessStartInfo
{
FileName = "dotnet",
WorkingDirectory = workDir.FullName,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
psi.ArgumentList.Add(hostDll);
psi.ArgumentList.Add("stdio");
psi.ArgumentList.Add("--config-file");
psi.ArgumentList.Add(Path.Combine(workDir.FullName, "devproxyrc.json"));
psi.ArgumentList.Add("--");
// Any child that stays alive while reading stdin; unmocked messages are blocked
// so it never receives input.
if (OperatingSystem.IsWindows())
{
psi.ArgumentList.Add("cmd.exe");
psi.ArgumentList.Add("/c");
psi.ArgumentList.Add("more");
}
else
{
psi.ArgumentList.Add("cat");
}

using var process = new Process { StartInfo = psi };
Assert.True(process.Start(), "Failed to start DevProxy stdio process.");
try
{
// Write an id-less notification and an id-bearing request in a single write so
// that they arrive in the same stdin read (#1757).
await process.StandardInput.WriteAsync(
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" +
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"accept_eula\"}}\n");
await process.StandardInput.FlushAsync();

using var cts = new CancellationTokenSource(s_startupTimeout);
var line = await ReadLineAsync(process.StandardOutput, cts.Token);
Assert.NotNull(line);

using var response = JsonDocument.Parse(line);
Assert.Equal(2, response.RootElement.GetProperty("id").GetInt32());
}
finally
{
StopHost(process);
}
}
finally
{
workDir.Delete(recursive: true);
}
}

[Fact]
public async Task Stdio_LargeMessageThroughStreamingChild_DoesNotDeadlock()
{
if (OperatingSystem.IsWindows())
{
return;
}

var hostDll = LocateHostDll();
Assert.True(File.Exists(hostDll), $"DevProxy host not built at {hostDll}. Run `dotnet build DevProxy` first.");

var workDir = Directory.CreateTempSubdirectory("devproxy-stdio-large-");
try
{
await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "stdio-mocks.json"), StdioMocksFile);
await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "devproxyrc.json"),
StdioConfigFile.Replace("\"blockUnmockedRequests\": true", "\"blockUnmockedRequests\": false", StringComparison.Ordinal));

var psi = new ProcessStartInfo
{
FileName = "dotnet",
WorkingDirectory = workDir.FullName,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
psi.ArgumentList.Add(hostDll);
psi.ArgumentList.Add("stdio");
psi.ArgumentList.Add("--config-file");
psi.ArgumentList.Add(Path.Combine(workDir.FullName, "devproxyrc.json"));
psi.ArgumentList.Add("--");
psi.ArgumentList.Add("cat");

using var process = new Process { StartInfo = psi };
Assert.True(process.Start(), "Failed to start DevProxy stdio process.");
try
{
// Larger than typical pipe buffers so the child blocks writing stdout
// while the proxy is still writing the message to its stdin.
var line = new string('x', 1024 * 1024);
using var cts = new CancellationTokenSource(s_startupTimeout);
var readTask = ReadLineAsync(process.StandardOutput, cts.Token);
await process.StandardInput.WriteAsync(line + "\n");
await process.StandardInput.FlushAsync();

Assert.Equal(line, await readTask);
}
finally
{
StopHost(process);
}
}
finally
{
workDir.Delete(recursive: true);
}
}

private static Process StartHost(string hostDll, string workDir)
{
var psi = new ProcessStartInfo
Expand Down Expand Up @@ -524,6 +651,35 @@ private static string LocateHostDll()
return Path.Combine(AppContext.BaseDirectory, "devproxy.dll");
}

private const string StdioConfigFile = """
{
"plugins": [
{
"name": "MockStdioResponsePlugin",
"enabled": true,
"pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll",
"configSection": "mockStdioResponsePlugin"
}
],
"mockStdioResponsePlugin": {
"mocksFile": "stdio-mocks.json",
"blockUnmockedRequests": true
},
"validateSchemas": false
}
""";

private const string StdioMocksFile = """
{
"mocks": [
{
"request": { "bodyFragment": "accept_eula" },
"response": { "stdout": "{\"jsonrpc\":\"2.0\",\"id\":@stdin.body.id,\"result\":{}}\n" }
}
]
}
""";

private const string ConfigFile = """
{
"$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/rc.schema.json",
Expand Down
79 changes: 79 additions & 0 deletions DevProxy.Tests/StdioLineFramerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text;
using DevProxy.Stdio;
using Xunit;

namespace DevProxy.Tests;

public class StdioLineFramerTests
{
private static string[] AsStrings(IEnumerable<byte[]> messages) =>
[.. messages.Select(m => Encoding.UTF8.GetString(m))];

[Fact]
public void Append_MultipleLinesInOneChunk_ReturnsEachLine()
{
var framer = new StdioLineFramer();

var messages = framer.Append(Encoding.UTF8.GetBytes(
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2}\n"));

Assert.Equal(
["{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", "{\"jsonrpc\":\"2.0\",\"id\":2}\n"],
AsStrings(messages));
Assert.Null(framer.Flush());
}

[Fact]
public void Append_LineSplitAcrossChunks_ReturnsLineOnceComplete()
{
var framer = new StdioLineFramer();

Assert.Empty(framer.Append(Encoding.UTF8.GetBytes("{\"id\":")));
Assert.Empty(framer.Append(Encoding.UTF8.GetBytes("1")));
var messages = framer.Append(Encoding.UTF8.GetBytes("}\n{\"id\":2"));

Assert.Equal(["{\"id\":1}\n"], AsStrings(messages));
Assert.Equal("{\"id\":2", Encoding.UTF8.GetString(framer.Flush()!));
Assert.Null(framer.Flush());
}

[Fact]
public void Append_CrLf_KeepsLineEndingIntact()
{
var framer = new StdioLineFramer();

var messages = framer.Append(Encoding.UTF8.GetBytes("a\r\nb\r\n"));

Assert.Equal(["a\r\n", "b\r\n"], AsStrings(messages));
}

[Fact]
public void Append_EmptyInput_ReturnsNothing()
{
var framer = new StdioLineFramer();

Assert.Empty(framer.Append([]));
Assert.Null(framer.Flush());
}

[Fact]
public void Append_LineLargerThanReadBuffer_IsReturnedAsSingleMessage()
{
var framer = new StdioLineFramer();
var line = new string('x', 10_000) + "\n";
var bytes = Encoding.UTF8.GetBytes(line);

var messages = new List<byte[]>();
for (var offset = 0; offset < bytes.Length; offset += 4096)
{
var length = Math.Min(4096, bytes.Length - offset);
messages.AddRange(framer.Append(bytes.AsSpan(offset, length)));
}

Assert.Equal([line], AsStrings(messages));
}
}
Loading
Loading