w4c-workflows-api/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs

293 lines
10 KiB
C#
Raw Normal View History

2026-09-13 08:35:17 +00:00
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
2026-09-13 08:35:17 +00:00
using Microsoft.Extensions.Configuration;
using w4c_workflows.Services.Execution;
using Xunit;
namespace w4c_workflows.Tests;
public class CSharpScriptExecutorTests
{
private readonly CSharpScriptExecutor _executor = new();
[Fact]
public async Task Executes_program_with_string_input()
{
using var dir = new TempDir();
dir.Write("Program.cs", """
using System.Text.Json;
public static class Program
{
public static object Main(string input)
{
using var doc = JsonDocument.Parse(input);
var n = doc.RootElement.GetProperty("n").GetInt32();
return new { squared = n * n };
}
}
""");
var result = await _executor.ExecuteAsync(
Invocation.For("csharp", "Program.cs", "{\"n\":5}", dir.Path), default);
Assert.True(result.Success, result.Error);
Assert.NotNull(result.Output);
using var output = JsonDocument.Parse(result.Output!);
Assert.Equal(25, output.RootElement.GetProperty("squared").GetInt32());
}
[Fact]
2026-09-03 11:34:50 +00:00
public async Task Compiles_only_entry_file_not_other_cs_files()
{
2026-09-03 11:34:50 +00:00
// Only the entry file (Program.cs) is compiled. Helper.cs exists in the
// directory but is NOT included, so referencing Helper.Add causes a
// compilation error. This prevents duplicate-type errors when multiple
// tasks each define their own Program class in the same workflow directory.
using var dir = new TempDir();
dir.Write("Helper.cs", """
public static class Helper
{
public static int Add(int a, int b) => a + b;
}
""");
dir.Write("Program.cs", """
using System.Text.Json;
public static class Program
{
public static object Main(string input)
{
using var doc = JsonDocument.Parse(input);
var a = doc.RootElement.GetProperty("a").GetInt32();
var b = doc.RootElement.GetProperty("b").GetInt32();
return new { sum = Helper.Add(a, b) };
}
}
""");
var result = await _executor.ExecuteAsync(
Invocation.For("csharp", "Program.cs", "{\"a\":2,\"b\":3}", dir.Path), default);
2026-09-03 11:34:50 +00:00
Assert.False(result.Success);
Assert.Contains("compilation failed", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Supports_custom_entry_function_name()
{
using var dir = new TempDir();
dir.Write("Program.cs", """
public static class Program
{
public static object Transform(string input) => new { echo = input };
}
""");
var result = await _executor.ExecuteAsync(
Invocation.For("csharp", "Program.cs", "{\"x\":1}", dir.Path, entryFunction: "Transform"), default);
Assert.True(result.Success, result.Error);
using var output = JsonDocument.Parse(result.Output!);
Assert.Equal("{\"x\":1}", output.RootElement.GetProperty("echo").GetString());
}
[Fact]
public async Task Supports_async_entry()
{
using var dir = new TempDir();
dir.Write("Program.cs", """
using System.Threading.Tasks;
public static class Program
{
public static async Task<object> Main(string input)
{
await Task.Yield();
return new { ok = true };
}
}
""");
var result = await _executor.ExecuteAsync(
Invocation.For("csharp", "Program.cs", "{}", dir.Path), default);
Assert.True(result.Success, result.Error);
Assert.Contains("\"ok\":true", result.Output);
}
[Fact]
public async Task Fails_with_compile_error()
{
using var dir = new TempDir();
dir.Write("Program.cs", "public static class Program { public static object Main(string input) { return ; } }");
var result = await _executor.ExecuteAsync(
Invocation.For("csharp", "Program.cs", "{}", dir.Path), default);
Assert.False(result.Success);
Assert.Contains("compilation failed", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Fails_when_entry_method_missing()
{
using var dir = new TempDir();
dir.Write("Program.cs", "public static class Program { public static int NotTheEntry() => 1; }");
var result = await _executor.ExecuteAsync(
Invocation.For("csharp", "Program.cs", "{}", dir.Path), default);
Assert.False(result.Success);
Assert.Contains("no static entry method", result.Error);
}
2026-09-03 14:44:39 +00:00
[Fact]
public async Task Runs_program_with_dynamic_input()
{
// Frontend stubs generate `dynamic input` (compiles to `object`), but the
// executor used to only match `string` params — so a valid stub compiled
// yet was never found at invoke time.
using var dir = new TempDir();
dir.Write("Main.cs", """
public static class Program
{
public static object Main(dynamic input)
{
return new
{
ok = true,
input = input
};
}
}
""");
var result = await _executor.ExecuteAsync(
Invocation.For("csharp", "Main.cs", "{\"x\":1}", dir.Path), default);
Assert.True(result.Success, result.Error);
using var output = JsonDocument.Parse(result.Output!);
Assert.True(output.RootElement.GetProperty("ok").GetBoolean());
Assert.Equal("{\"x\":1}", output.RootElement.GetProperty("input").GetString());
}
[Fact]
public async Task Reports_expression_body_followed_by_block_as_compile_error()
{
// Regression: an LLM/agent edit turned the stub body into `=> { ... }`,
// which is invalid C#. This is exactly the reported failure
// "Main.cs(5,48): error CS1525: Invalid expression term '{'".
using var dir = new TempDir();
dir.Write("Main.cs", """
public static class Program
{
public static object Main(dynamic input) =>
{
return new
{
ok = true,
input = input
};
}
}
""");
var result = await _executor.ExecuteAsync(
Invocation.For("csharp", "Main.cs", "{}", dir.Path), default);
Assert.False(result.Success);
Assert.Contains("compilation failed", result.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CS1525", result.Error, StringComparison.OrdinalIgnoreCase);
}
2026-09-13 08:35:17 +00:00
// ------------------------------------------------------- P0-6 hardening
[Fact]
public async Task Compiled_assembly_is_cached_by_source_hash()
{
using var dir = new TempDir();
// Unique source so the cache key is unique to this test.
var marker = Guid.NewGuid().ToString("N");
dir.Write("Program.cs", $$"""
public static class Program
{
public static object Main(string input) => new { marker = "{{marker}}" };
}
""");
var source = File.ReadAllText(Path.Combine(dir.Path, "Program.cs"));
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source)));
Assert.False(CacheContains(key));
var invocation = Invocation.For("csharp", "Program.cs", "{}", dir.Path);
var first = await _executor.ExecuteAsync(invocation, default);
Assert.True(first.Success, first.Error);
Assert.True(CacheContains(key));
// Second run is a cache hit; it must succeed and the image is reused.
var second = await _executor.ExecuteAsync(invocation, default);
Assert.True(second.Success, second.Error);
Assert.Contains(marker, second.Output);
}
[Fact]
public async Task Times_out_when_the_entry_exceeds_the_deadline()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Workflows:CSharpTimeoutSeconds"] = "1" })
.Build();
var executor = new CSharpScriptExecutor(config);
using var dir = new TempDir();
dir.Write("Program.cs", """
using System.Threading.Tasks;
public static class Program
{
public static async Task<object> Main(string input)
{
await Task.Delay(10000);
return new { ok = true };
}
}
""");
var result = await executor.ExecuteAsync(
Invocation.For("csharp", "Program.cs", "{}", dir.Path), default);
Assert.False(result.Success);
Assert.Contains("timed out", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Cancellation_is_propagated_not_swallowed()
{
using var dir = new TempDir();
dir.Write("Program.cs", """
using System.Threading.Tasks;
public static class Program
{
public static async Task<object> Main(string input)
{
await Task.Delay(10000);
return new { ok = true };
}
}
""");
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => _executor.ExecuteAsync(Invocation.For("csharp", "Program.cs", "{}", dir.Path), cts.Token));
}
private static bool CacheContains(string key)
{
var field = typeof(CSharpScriptExecutor).GetField(
"AssemblyCache", BindingFlags.NonPublic | BindingFlags.Static)!;
var cache = field.GetValue(null)!;
return (bool)cache.GetType().GetMethod("ContainsKey")!.Invoke(cache, new object[] { key })!;
}
}