using System.Text; using System.Text.Json.Nodes; using Agc.Unity.Attach; var tests = new (string Name, Func 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()); Equal(123, result["pid"]!.GetValue()); 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()); Equal("unity-editor", result["adapter"]!.GetValue()); Equal("generation-1", result["generation"]!.GetValue()); 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()); Equal(false, result["dispatched"]!.GetValue()); 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()); 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()); Equal(false, Result(await f.Call("status"))["connected"]!.GetValue()); }), ("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()); }), ("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()); }), ("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()); Equal(true, result["dispatched"]!.GetValue()); 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()); Equal(false, result["dispatched"]!.GetValue()); f.Backend.Execute = FakeBackend.Succeed; Equal("completed", Result(await f.Call("execute"))["status"]!.GetValue()); }), ("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()); Equal(true, result["dispatched"]!.GetValue()); f.Backend.Execute = FakeBackend.Succeed; Equal("completed", Result(await f.Call("execute"))["status"]!.GetValue()); }), ("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()); await f.Call("disconnect"); Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue()); 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()); }), ("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()); }), ("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()); 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()); Check(timer.Elapsed < TimeSpan.FromSeconds(2)); Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue()); }), ("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()); Equal(false, result["dispatched"]!.GetValue()); Equal("UnityBusy", Result(await f.Call("execute"))["error"]!["code"]!.GetValue()); 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()); Equal(false, second["dispatched"]!.GetValue()); 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()); } var arbitrary = f.Request("execute"); arbitrary["params"]!["payloadPath"] = "bad.dll"; Equal(false, Result(await f.Host.HandleAsync(arbitrary))["dispatched"]!.GetValue()); 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()); } var badId = f.Request("detect"); badId["id"] = 0; Equal("UnityIdInvalid", (await f.Host.HandleAsync(badId))["error"]!["code"]!.GetValue()); 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()); Equal("needs-reconciliation", Result(await f.Call("execute"))["status"]!.GetValue()); 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(); Check(message.StartsWith("")); 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(); Check(!message.Contains(profile, StringComparison.OrdinalIgnoreCase)); Check(message.Contains("example.cs(3,14): error CS1002: ; expected")); Check(message.StartsWith("") || message.StartsWith("")); }), ("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()); Equal(1L, reply["id"]!.GetValue()); }), ("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()); }) }; 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 expected, T actual) { if (!EqualityComparer.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 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 Targets; public string MetadataProject; public int ConnectCalls, ExecuteCalls, WaitCalls; public Action? OnConnect; public Func> 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 Succeed(Action dispatch, CancellationToken token) { token.ThrowIfCancellationRequested(); dispatch(); return Task.FromResult(Completed(42)); } public Task> DetectAsync(CancellationToken token) { token.ThrowIfCancellationRequested(); return Task.FromResult(Targets); } public Task ConnectAsync(EditorTarget target, CancellationToken token) { ConnectCalls++; OnConnect?.Invoke(); return StatusAsync(token); } public Task StatusAsync(CancellationToken token) => Task.FromResult(new JsonObject { ["state"] = "completed", ["generation"] = "generation-1", ["result"] = new JsonObject { ["project"] = MetadataProject, ["version"] = "2022.3.0f1" } }); public Task ExecuteAsync(string code, Action onDispatch, CancellationToken token) { ExecuteCalls++; return Execute(onDispatch, token); } public Task 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); }