2026-09-01 16:37:53 +00:00
|
|
|
using System.Text.Json;
|
|
|
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
|
using w4c_workflows.Services.Execution;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace w4c_workflows.Tests;
|
|
|
|
|
|
|
|
|
|
public class SubprocessScriptExecutorTests
|
|
|
|
|
{
|
|
|
|
|
private static SubprocessScriptExecutor Shell(int timeoutSeconds = 60)
|
|
|
|
|
{
|
|
|
|
|
var config = new ConfigurationBuilder()
|
|
|
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
|
|
|
{
|
|
|
|
|
["Workflows:TaskTimeoutSeconds"] = timeoutSeconds.ToString(),
|
|
|
|
|
})
|
|
|
|
|
.Build();
|
|
|
|
|
return new SubprocessScriptExecutor("shell", "sh", config);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Shell_reads_stdin_and_returns_json_output()
|
|
|
|
|
{
|
|
|
|
|
using var dir = new TempDir();
|
|
|
|
|
dir.Write("task.sh", """
|
|
|
|
|
#!/bin/sh
|
|
|
|
|
read input
|
|
|
|
|
echo "{\"ok\":true,\"value\":$input}"
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
var result = await Shell().ExecuteAsync(
|
|
|
|
|
Invocation.For("shell", "task.sh", "{\"x\":1}", dir.Path), default);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, result.Error);
|
|
|
|
|
Assert.Equal(0, result.ExitCode);
|
|
|
|
|
using var output = JsonDocument.Parse(result.Output!);
|
|
|
|
|
Assert.True(output.RootElement.GetProperty("ok").GetBoolean());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Shell_nonzero_exit_reports_stderr_as_error()
|
|
|
|
|
{
|
|
|
|
|
using var dir = new TempDir();
|
|
|
|
|
dir.Write("task.sh", """
|
|
|
|
|
#!/bin/sh
|
|
|
|
|
echo "boom" 1>&2
|
|
|
|
|
exit 3
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
var result = await Shell().ExecuteAsync(
|
|
|
|
|
Invocation.For("shell", "task.sh", "{}", dir.Path), default);
|
|
|
|
|
|
|
|
|
|
Assert.False(result.Success);
|
|
|
|
|
Assert.Equal(3, result.ExitCode);
|
|
|
|
|
Assert.Contains("boom", result.Error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Shell_non_json_stdout_is_wrapped_as_string()
|
|
|
|
|
{
|
|
|
|
|
using var dir = new TempDir();
|
|
|
|
|
dir.Write("task.sh", "#!/bin/sh\necho \"hello world\"\n");
|
|
|
|
|
|
|
|
|
|
var result = await Shell().ExecuteAsync(
|
|
|
|
|
Invocation.For("shell", "task.sh", "{}", dir.Path), default);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, result.Error);
|
|
|
|
|
using var output = JsonDocument.Parse(result.Output!);
|
|
|
|
|
Assert.Equal("hello world", output.RootElement.GetString());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Shell_is_killed_when_it_exceeds_the_time_limit()
|
|
|
|
|
{
|
|
|
|
|
using var dir = new TempDir();
|
|
|
|
|
dir.Write("task.sh", "#!/bin/sh\nsleep 5\n");
|
|
|
|
|
|
|
|
|
|
var result = await Shell(timeoutSeconds: 1).ExecuteAsync(
|
|
|
|
|
Invocation.For("shell", "task.sh", "{}", dir.Path), default);
|
|
|
|
|
|
|
|
|
|
Assert.False(result.Success);
|
|
|
|
|
Assert.Contains("time limit", result.Error);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 12:06:06 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task Shell_honours_bash_shebang_for_bash_only_scripts()
|
|
|
|
|
{
|
|
|
|
|
if (ProcessRunner.FindInPath("bash") is null)
|
|
|
|
|
return; // no bash on this host — the fallback path is covered elsewhere
|
|
|
|
|
|
|
|
|
|
using var dir = new TempDir();
|
|
|
|
|
dir.Write("task.sh", """
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
cat >/dev/null || true
|
|
|
|
|
echo '{"ok":true}'
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
var result = await Shell().ExecuteAsync(
|
|
|
|
|
Invocation.For("shell", "task.sh", "{}", dir.Path), default);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, result.Error);
|
|
|
|
|
using var output = JsonDocument.Parse(result.Output!);
|
|
|
|
|
Assert.True(output.RootElement.GetProperty("ok").GetBoolean());
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task Python_reads_stdin_and_returns_json_output()
|
|
|
|
|
{
|
|
|
|
|
var executor = new SubprocessScriptExecutor("python", "python3", new ConfigurationBuilder().Build());
|
|
|
|
|
if (!executor.IsAvailable())
|
|
|
|
|
return; // python3 not installed on this host — availability is covered by RuntimeRegistryTests
|
|
|
|
|
|
|
|
|
|
using var dir = new TempDir();
|
|
|
|
|
dir.Write("task.py", """
|
|
|
|
|
import sys, json
|
|
|
|
|
print(json.dumps({"echo": sys.stdin.read().strip()}))
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
var result = await executor.ExecuteAsync(
|
|
|
|
|
Invocation.For("python", "task.py", "{\"x\":1}", dir.Path), default);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, result.Error);
|
|
|
|
|
Assert.Contains("\\\"x\\\":1", result.Output);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Javascript_runs_under_node()
|
|
|
|
|
{
|
|
|
|
|
var executor = new SubprocessScriptExecutor("javascript", "node", new ConfigurationBuilder().Build());
|
|
|
|
|
if (!executor.IsAvailable())
|
|
|
|
|
return; // node not installed on this host — availability is covered by RuntimeRegistryTests
|
|
|
|
|
|
|
|
|
|
using var dir = new TempDir();
|
|
|
|
|
dir.Write("task.js", """
|
|
|
|
|
let input = "";
|
|
|
|
|
process.stdin.on("data", (c) => (input += c));
|
|
|
|
|
process.stdin.on("end", () => {
|
|
|
|
|
const data = JSON.parse(input);
|
|
|
|
|
console.log(JSON.stringify({ doubled: data.n * 2 }));
|
|
|
|
|
});
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
var result = await executor.ExecuteAsync(
|
|
|
|
|
Invocation.For("javascript", "task.js", "{\"n\":21}", dir.Path), default);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success, result.Error);
|
|
|
|
|
using var output = JsonDocument.Parse(result.Output!);
|
|
|
|
|
Assert.Equal(42, output.RootElement.GetProperty("doubled").GetInt32());
|
|
|
|
|
}
|
|
|
|
|
}
|