293 lines
10 KiB
C#
293 lines
10 KiB
C#
using System.Reflection;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
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]
|
|
public async Task Compiles_only_entry_file_not_other_cs_files()
|
|
{
|
|
// 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);
|
|
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// ------------------------------------------------------- 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 })!;
|
|
}
|
|
}
|