Files
Genarrative/plugins/agc-unity-editor/dotnet/tests/Program.cs
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +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);
}