Files
Genarrative/plugins/agc-unity-editor/dotnet/tests/Program.cs
T
kdletters 3fc3399032
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m41s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m11s
Project CI / Repository checks (pull_request) Successful in 4m22s
Project CI / Frontend tests (pull_request) Successful in 5m52s
Project CI / Native shell tests (pull_request) Successful in 9m52s
Project CI / Backend tests (pull_request) Successful in 11m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m41s
接入 DotCraft Unity 编辑器插件
新增内置 Unity 插件与自包含 Attach helper,固定上游版本并保留许可证
统一 Runner 执行归属、项目身份校验、回执确认和不确定结果阻断
接入 Unity 工程导入、Agent 工具目录及 Windows 构建分发
补齐插件测试、CI 执行入口和 Windows .NET 10 工具链检查
记录真实 Unity 连接、执行、重载恢复及 Runner 边界验证
2026-09-18 19:44:36 +08:00

339 lines
20 KiB
C#

using System.Text;
using System.Text.Json.Nodes;
using Agc.Unity.Attach;
var tests = new (string Name, Func<Task> Run)[]
{
("detect is read-only and disconnected", async () =>
{
await using var f = new Fixture();
var result = Result(await f.Call("detect"));
Equal(false, result["connected"]!.GetValue<bool>());
Equal(123, result["pid"]!.GetValue<int>());
Equal(0, f.Backend.ConnectCalls);
}),
("connection publishes verified identity", async () =>
{
await using var f = new Fixture();
var result = Result(await f.Call("connect"));
Equal(true, result["connected"]!.GetValue<bool>());
Equal("unity-editor", result["adapter"]!.GetValue<string>());
Equal("generation-1", result["generation"]!.GetValue<string>());
Check(result["startedUtc"] != null);
}),
("cross-project PID cannot attach", async () =>
{
await using var f = new Fixture();
f.Backend.Targets = [f.Backend.Targets[0] with { ProjectPath = Path.GetTempPath() }];
var request = f.Request("execute"); request["params"]!["processId"] = 123;
var result = Result(await f.Host.HandleAsync(request));
Equal("failed", result["status"]!.GetValue<string>());
Equal(false, result["dispatched"]!.GetValue<bool>());
Equal(0, f.Backend.ConnectCalls);
}),
("ambiguous target is rejected without injection", async () =>
{
await using var f = new Fixture();
f.Backend.Targets = [f.Backend.Targets[0], f.Backend.Targets[0] with { Pid = 124 }];
Equal("UnityTargetAmbiguous", (await f.Call("connect"))["error"]!["code"]!.GetValue<string>());
Equal(0, f.Backend.ConnectCalls);
}),
("PID start identity cannot change during connect", async () =>
{
await using var f = new Fixture();
f.Backend.OnConnect = () => f.Backend.Targets = [f.Backend.Targets[0] with { StartedUtc = DateTime.UtcNow }];
Equal("UnityTargetChanged", (await f.Call("connect"))["error"]!["code"]!.GetValue<string>());
Equal(false, Result(await f.Call("status"))["connected"]!.GetValue<bool>());
}),
("handshake project mismatch rejects connection", async () =>
{
await using var f = new Fixture();
f.Backend.MetadataProject = Path.GetTempPath();
Equal("UnityHandshakeInvalid", (await f.Call("connect"))["error"]!["code"]!.GetValue<string>());
}),
("failed connect clears previous connection", async () =>
{
await using var f = new Fixture();
await f.Call("connect");
var request = f.Request("connect"); request["params"]!["projectPath"] = "relative";
Check((await f.Host.HandleAsync(request))["error"] != null);
Equal(false, Result(await f.Call("status"))["connected"]!.GetValue<bool>());
}),
("actual result including null is preserved", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (dispatch, _) => { dispatch(); return Task.FromResult(FakeBackend.Completed(null)); };
var result = Result(await f.Call("execute"));
Equal("completed", result["status"]!.GetValue<string>());
Equal(true, result["dispatched"]!.GetValue<bool>());
Check(result.ContainsKey("result") && result["result"] == null);
}),
("compiler failure is known unsent and allows repaired code", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (_, _) => throw new InvalidOperationException("CS0103: missing identifier");
var result = Result(await f.Call("execute"));
Equal("failed", result["status"]!.GetValue<string>()); Equal(false, result["dispatched"]!.GetValue<bool>());
f.Backend.Execute = FakeBackend.Succeed;
Equal("completed", Result(await f.Call("execute"))["status"]!.GetValue<string>());
}),
("known runtime failure does not latch", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (dispatch, _) => { dispatch(); return Task.FromResult(new JsonObject { ["state"] = "failed", ["generation"] = "g", ["executionId"] = "e", ["error"] = "A runtime exception" }); };
var result = Result(await f.Call("execute"));
Equal("failed", result["status"]!.GetValue<string>()); Equal(true, result["dispatched"]!.GetValue<bool>());
f.Backend.Execute = FakeBackend.Succeed;
Equal("completed", Result(await f.Call("execute"))["status"]!.GetValue<string>());
}),
("unknown is never replayed and disconnect cannot unlatch", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (dispatch, _) => { dispatch(); return Task.FromResult(new JsonObject { ["state"] = "unknown" }); };
Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue<string>());
await f.Call("disconnect");
Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue<string>());
Equal(1, f.Backend.ExecuteCalls);
}),
("lost execution receipt latches", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (dispatch, _) => { dispatch(); return Task.FromResult(new JsonObject { ["state"] = "lost" }); };
Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue<string>());
}),
("malformed receipt latches", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (dispatch, _) => { dispatch(); return Task.FromResult(new JsonObject { ["state"] = "completed" }); };
Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue<string>());
}),
("wait completion does not resend execution", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (dispatch, _) => { dispatch(); return Task.FromResult(new JsonObject { ["state"] = "running", ["generation"] = "generation-1", ["executionId"] = "execution-1" }); };
Equal("completed", Result(await f.Call("execute"))["status"]!.GetValue<string>());
Equal(1, f.Backend.ExecuteCalls); Equal(1, f.Backend.WaitCalls);
}),
("post-dispatch timeout latches within deadline", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = async (dispatch, token) => { dispatch(); await Task.Delay(Timeout.Infinite, token); return FakeBackend.Completed(1); };
var request = f.Request("execute"); request["params"]!["timeoutMs"] = 40;
var timer = System.Diagnostics.Stopwatch.StartNew();
Equal("needs-reconciliation", Result(await f.Host.HandleAsync(request))["status"]!.GetValue<string>());
Check(timer.Elapsed < TimeSpan.FromSeconds(2));
Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue<string>());
}),
("late compiler cannot dispatch after timeout", async () =>
{
await using var f = new Fixture();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var sent = false;
f.Backend.Execute = async (dispatch, _) =>
{
entered.SetResult(); await release.Task;
try { dispatch(); sent = true; return FakeBackend.Completed(1); }
finally { finished.SetResult(); }
};
var request = f.Request("execute"); request["params"]!["timeoutMs"] = 100;
var pending = f.Host.HandleAsync(request);
await entered.Task;
var result = Result(await pending);
Equal("failed", result["status"]!.GetValue<string>()); Equal(false, result["dispatched"]!.GetValue<bool>());
Equal("UnityBusy", Result(await f.Call("execute"))["error"]!["code"]!.GetValue<string>());
release.SetResult(); await finished.Task;
Equal(false, sent);
}),
("concurrent execution is rejected, not queued", async () =>
{
await using var f = new Fixture();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
f.Backend.Execute = async (dispatch, _) => { dispatch(); entered.SetResult(); await release.Task; return FakeBackend.Completed(1); };
var first = f.Call("execute"); await entered.Task;
var second = Result(await f.Call("execute"));
Equal("UnityBusy", second["error"]!["code"]!.GetValue<string>());
Equal(false, second["dispatched"]!.GetValue<bool>());
release.SetResult(); await first; Equal(1, f.Backend.ExecuteCalls);
}),
("UTF-8 code limit, NUL and payload path rejected", async () =>
{
await using var f = new Fixture();
foreach (var code in new[] { "", "a\0b", new string('中', 44_000) })
{
var request = f.Request("execute"); request["params"]!["code"] = code;
Equal(false, Result(await f.Host.HandleAsync(request))["dispatched"]!.GetValue<bool>());
}
var arbitrary = f.Request("execute"); arbitrary["params"]!["payloadPath"] = "bad.dll";
Equal(false, Result(await f.Host.HandleAsync(arbitrary))["dispatched"]!.GetValue<bool>());
Equal(0, f.Backend.ExecuteCalls);
}),
("timeout and protocol bounds are validated", async () =>
{
await using var f = new Fixture();
foreach (var timeout in new[] { 0, -1, 60_001 })
{
var request = f.Request("execute"); request["params"]!["timeoutMs"] = timeout;
Equal(false, Result(await f.Host.HandleAsync(request))["dispatched"]!.GetValue<bool>());
}
var badId = f.Request("detect"); badId["id"] = 0;
Equal("UnityIdInvalid", (await f.Host.HandleAsync(badId))["error"]!["code"]!.GetValue<string>());
var badProtocol = f.Request("detect"); badProtocol["protocol"] = "other";
Check((await f.Host.HandleAsync(badProtocol))["error"] != null);
}),
("oversized completed result latches before accepting another execution", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (dispatch, _) => { dispatch(); return Task.FromResult(FakeBackend.Completed(new string('x', HelperHost.MaximumMessageBytes))); };
Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue<string>());
Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue<string>());
Equal(1, f.Backend.ExecuteCalls);
}),
("diagnostics redact credentials", async () =>
{
await using var f = new Fixture();
f.Backend.Execute = (_, _) => throw new InvalidOperationException("Bearer secret123 api_key=xyz token: abc");
var result = Result(await f.Call("execute")).ToJsonString();
Check(!result.Contains("secret123") && !result.Contains("xyz") && !result.Contains("abc"));
}),
("compiler diagnostics redact slash and backslash cache paths without losing locations", async () =>
{
await using var f = new Fixture();
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var cache = Path.Combine(local, "Genarrative", "UnityAttach");
foreach (var separator in new[] { '/', '\\' })
{
var source = Path.Combine(cache, "snippets", "example.cs").Replace('\\', separator).Replace('/', separator);
f.Backend.Execute = (_, _) => throw new InvalidOperationException(source + "(1,8): error CS0103: The name 'MissingValue' does not exist in the current context");
var message = Result(await f.Call("execute"))["error"]!["message"]!.GetValue<string>();
Check(message.StartsWith("<unity-cache>"));
Check(!message.Contains(local, StringComparison.OrdinalIgnoreCase));
Check(message.Contains("example.cs(1,8): error CS0103: The name 'MissingValue' does not exist in the current context"));
}
}),
("redirected per-user compiler path is redacted while error prose remains", async () =>
{
await using var f = new Fixture();
var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile).Replace('\\', '/');
var source = profile + "/AppData/Local/Packages/Example.Host/LocalCache/Local/Genarrative/UnityAttach/snippets/example.cs";
f.Backend.Execute = (_, _) => throw new InvalidOperationException(source + "(3,14): error CS1002: ; expected");
var message = Result(await f.Call("execute"))["error"]!["message"]!.GetValue<string>();
Check(!message.Contains(profile, StringComparison.OrdinalIgnoreCase));
Check(message.Contains("example.cs(3,14): error CS1002: ; expected"));
Check(message.StartsWith("<user>") || message.StartsWith("<local>"));
}),
("NDJSON validates malformed UTF-8 and honors message bound", async () =>
{
await using var f = new Fixture();
using var input = new MemoryStream([0xff, (byte)'\n']); using var output = new MemoryStream();
await ProtocolServer.RunAsync(input, output, f.Host);
Check(Encoding.UTF8.GetString(output.ToArray()).Contains("UnityProtocolInvalid"));
using var oversized = new MemoryStream(new byte[HelperHost.MaximumMessageBytes + 1]);
var reader = new BoundedLineReader(oversized, HelperHost.MaximumMessageBytes);
try { await reader.ReadAsync(); throw new Exception("Expected message limit"); }
catch (HostException error) { Equal("UnityMessageTooLarge", error.Code); }
}),
("NDJSON round trip echoes id and protocol", async () =>
{
await using var f = new Fixture();
using var input = new MemoryStream(Encoding.UTF8.GetBytes(f.Request("detect").ToJsonString() + "\n"));
using var output = new MemoryStream();
await ProtocolServer.RunAsync(input, output, f.Host);
var reply = JsonNode.Parse(output.ToArray())!;
Equal(HelperHost.Protocol, reply["protocol"]!.GetValue<string>());
Equal(1L, reply["id"]!.GetValue<long>());
}),
("Windows canonical extended prefix compares equally", () =>
{
if (OperatingSystem.IsWindows()) Check(ProjectIdentity.Same(@"\\?\C:\Unity\Project", @"C:\Unity\Project\"));
return Task.CompletedTask;
}),
("Windows short and long existing paths share physical identity", async () =>
{
if (!OperatingSystem.IsWindows()) return;
await using var f = new Fixture();
var longPath = ProjectIdentity.Normalize(f.Project);
var buffer = new StringBuilder(32768);
var length = NativePaths.GetShortPathNameW(longPath, buffer, (uint)buffer.Capacity);
Check(length > 0 && length < buffer.Capacity);
var shortPath = buffer.ToString();
Check(ProjectIdentity.Same(shortPath, longPath));
Equal(longPath, ProjectIdentity.Normalize(shortPath));
f.Backend.Targets = [f.Backend.Targets[0] with { ProjectPath = shortPath }];
var request = f.Request("connect"); request["params"]!["projectPath"] = longPath;
Equal(true, Result(await f.Host.HandleAsync(request))["connected"]!.GetValue<bool>());
})
};
var failures = 0;
foreach (var test in tests)
{
try { await test.Run().WaitAsync(TimeSpan.FromSeconds(10)); Console.WriteLine("PASS " + test.Name); }
catch (Exception error) { failures++; Console.Error.WriteLine("FAIL " + test.Name + ": " + error); }
}
Console.WriteLine($"Unity helper: {tests.Length - failures}/{tests.Length} passed (fake Editor transport; no Unity injection).");
return failures == 0 ? 0 : 1;
static JsonObject Result(JsonObject response) => response["result"]?.AsObject() ?? throw new Exception(response.ToJsonString());
static void Check(bool condition) { if (!condition) throw new Exception("Assertion failed"); }
static void Equal<T>(T expected, T actual) { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"Expected {expected}; got {actual}"); }
sealed class Fixture : IAsyncDisposable
{
public string Project { get; } = Path.Combine(Path.GetTempPath(), "agc-unity-helper-tests-" + Guid.NewGuid().ToString("N"));
public FakeBackend Backend { get; }
public HelperHost Host { get; }
private long nextId;
public Fixture()
{
foreach (var name in new[] { "Assets", "Packages", "ProjectSettings" }) Directory.CreateDirectory(Path.Combine(Project, name));
File.WriteAllText(Path.Combine(Project, "ProjectSettings", "ProjectVersion.txt"), "m_EditorVersion: 2022.3.0f1\n");
Backend = new FakeBackend(Project); Host = new HelperHost(Backend);
}
public JsonObject Request(string method) => new()
{
["jsonrpc"] = "2.0", ["protocol"] = HelperHost.Protocol, ["id"] = Interlocked.Increment(ref nextId), ["method"] = method,
["params"] = method == "execute" ? new JsonObject { ["projectPath"] = Project, ["code"] = "return 42;" } : new JsonObject { ["projectPath"] = Project }
};
public Task<JsonObject> Call(string method) => Host.HandleAsync(Request(method));
public async ValueTask DisposeAsync()
{
await Host.DisposeAsync();
if (!Path.GetFullPath(Project).StartsWith(Path.GetFullPath(Path.GetTempPath()), StringComparison.OrdinalIgnoreCase)
|| !Path.GetFileName(Project).StartsWith("agc-unity-helper-tests-", StringComparison.Ordinal)) throw new Exception("Unsafe test cleanup path");
Directory.Delete(Project, true);
}
}
sealed class FakeBackend : IEditorBackend
{
public IReadOnlyList<EditorTarget> Targets;
public string MetadataProject;
public int ConnectCalls, ExecuteCalls, WaitCalls;
public Action? OnConnect;
public Func<Action, CancellationToken, Task<JsonObject>> Execute = Succeed;
public FakeBackend(string project)
{
MetadataProject = project;
Targets = [new EditorTarget(123, new DateTime(2026, 9, 18, 0, 0, 0, DateTimeKind.Utc), project, "Unity.exe")];
}
public static JsonObject Completed(JsonNode? result) => new() { ["state"] = "completed", ["generation"] = "generation-1", ["executionId"] = "execution-1", ["result"] = result };
public static Task<JsonObject> Succeed(Action dispatch, CancellationToken token) { token.ThrowIfCancellationRequested(); dispatch(); return Task.FromResult(Completed(42)); }
public Task<IReadOnlyList<EditorTarget>> DetectAsync(CancellationToken token) { token.ThrowIfCancellationRequested(); return Task.FromResult(Targets); }
public Task<JsonObject> ConnectAsync(EditorTarget target, CancellationToken token) { ConnectCalls++; OnConnect?.Invoke(); return StatusAsync(token); }
public Task<JsonObject> StatusAsync(CancellationToken token) => Task.FromResult(new JsonObject { ["state"] = "completed", ["generation"] = "generation-1", ["result"] = new JsonObject { ["project"] = MetadataProject, ["version"] = "2022.3.0f1" } });
public Task<JsonObject> ExecuteAsync(string code, Action onDispatch, CancellationToken token) { ExecuteCalls++; return Execute(onDispatch, token); }
public Task<JsonObject> WaitAsync(string id, CancellationToken token) { WaitCalls++; return Task.FromResult(Completed(42)); }
public Task DisconnectAsync(CancellationToken token) => Task.CompletedTask;
public void ForgetSelection() { }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
static class NativePaths
{
[System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, SetLastError = true)]
internal static extern uint GetShortPathNameW(string longPath, StringBuilder shortPath, uint size);
}